From 6aae65df90741534a15c50cdad93891349275acb Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Sun, 22 Oct 2023 13:06:11 +0200 Subject: [PATCH] feat: initial code (#1) --- .github/dependabot.yml | 12 ++ .github/workflows/release.yml | 38 +++++ .github/workflows/test.yml | 40 ++++++ .gitignore | 1 + .golangci.yml | 51 +++++++ .goreleaser.yml | 35 +++++ Makefile | 36 +++++ README.md | 39 ++++++ assets/index.html | 31 +++++ assets/style.css | 67 +++++++++ go.mod | 13 ++ go.sum | 10 ++ main.go | 254 ++++++++++++++++++++++++++++++++++ 13 files changed, 627 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yml create mode 100644 Makefile create mode 100644 README.md create mode 100644 assets/index.html create mode 100644 assets/style.css create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c023415 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dc6517e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +on: + push: + tags: + - '*' + +name: release +jobs: + release: + + runs-on: ubuntu-latest + env: + GO_VERSION: "1.21" + + steps: + - name: Install Go + if: success() + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2da7c90 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +on: + push: + branches: + - main + pull_request: + +name: run tests +jobs: + test: + + runs-on: ubuntu-latest + env: + GOOS: js + GOARCH: wasm + GO_VERSION: "1.21" + GOLANGCI_LINT_VERSION: v1.55.0 + + steps: + - name: Install Go + if: success() + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run linter + uses: golangci/golangci-lint-action@v3 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + skip-pkg-cache: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1c8b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/dist diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..af23fde --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,51 @@ +run: + tests: false + deadline: 5m + +linters-settings: + gofumpt: + extra-rules: true + +linters: + enable-all: true + disable: + - interfacebloat + - sqlclosecheck # not relevant (SQL) + - rowserrcheck # not relevant (SQL) + - execinquery # not relevant (SQL) + - interfacer # deprecated + - scopelint # deprecated + - maligned # deprecated + - golint # deprecated + - deadcode # deprecated + - exhaustivestruct # deprecated + - ifshort # deprecated + - nosnakecase # deprecated + - structcheck # deprecated + - varcheck # deprecated + - cyclop # duplicate of gocyclo + - depguard + - exhaustive + - exhaustruct + - forcetypeassert + - funlen + - gochecknoglobals + - gochecknoinits + - gocognit + - gocyclo + - goerr113 + - gomnd + - ireturn + - nestif + - nlreturn + - nonamedreturns + - tagliatelle + - varnamelen + - wrapcheck + - wsl + +issues: + exclude-use-default: false + exclude: + - 'ST1000: at least one file in a package should have a package comment' + - 'package-comments: should have a package comment' diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..0c28fa6 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,35 @@ +project_name: solar +dist: dist + +gomod: + proxy: true + +builds: + - main: ./ + binary: "{{ .ProjectName }}" + goos: + - js + goarch: + - wasm + env: + - CGO_ENABLED=0 + +archives: + - format: binary + name_template: '{{ .Binary }}' + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^doc:' + - '^tests:' + - '^test:' + - '^chore:' + +checksum: + name_template: '{{ .ProjectName }}_checksums.txt' + +snapshot: + name_template: "{{ .Tag }}" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9494d82 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +GOOS=js +GOARCH=wasm + +export GOOS +export GOARCH + +# Format all files +fmt: + @echo "==> Formatting source" + @gofmt -s -w $(shell find . -type f -name '*.go' -not -path "./vendor/*") + @echo "==> Done" +.PHONY: fmt + +# Tidy the go.mod file +tidy: + @echo "==> Cleaning go.mod" + @go mod tidy + @echo "==> Done" +.PHONY: tidy + +# Lint the project +lint: + @echo "==> Linting Go files" + @golangci-lint run ./... +.PHONY: lint + +# Run all tests +test: + @go test -cover ./... +.PHONY: test + +# Build the commands +build: + @goreleaser release --clean --snapshot +.PHONY: build + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c203b16 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +![Logo](http://svg.wiersma.co.za/glasslabs/module?title=CLOCK&tag=a%20simple%20clock%20module) + +Clock is a simple clock module for [looking glass](http://github.com/glasslabs/looking-glass) + +![Screenshot](.readme/screenshot.png) + +## Usage + +Clone the clock into a path under your modules path and add the module path +to under modules in your configuration. + +```yaml +modules: + - name: simple-clock + url: https://github.com/glasslabs/clock/releases/download/v1.0.0/clock.wasm + position: top:right + config: + timeFormat: 15:04 +``` + +## Configuration + +### Time Format (timeFormat) + +*Default: 15:04* + +Formats the time display of the clock using Go's [time formatting syntax](https://golang.org/pkg/time/#Time.Format). + +### Date Format (dateFormat) + +*Default: Monday, January 2* + +Formats the date display of the clock using Go's [time formatting syntax](https://golang.org/pkg/time/#Time.Format). + +### Time Zone (timezone) + +*Default: Local* + +The timezone name according to [IANA Time Zone databse](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) \ No newline at end of file diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..680a175 --- /dev/null +++ b/assets/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + 0% + + + + + + kWh + + \ No newline at end of file diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..8b4d0f0 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,67 @@ +.chart { + r: var(--radius); + transform: rotate(-90deg); + transform-origin: center center; + fill: none; + stroke-width: 5; + stroke: #444; + stroke-dasharray: calc(2 * pi * var(--radius)); + stroke-dashoffset: calc(2 * pi * var(--radius) * (1 - ((var(--percentage) / 100) * -0.4))); + transition: stroke-dashoffset 0.3s linear 0.1s; +} + +#load.chart { + --radius: 140; + stroke: rgb(115, 191, 105); +} + +#pv.chart { + --radius: 130; + stroke: rgb(255, 152, 48); +} + +#battery.chart { + --radius: 120; + stroke: rgb(87, 148, 242); +} + +#grid.chart { + --radius: 110; + stroke: rgb(242, 73, 92); +} + +#icons .off { + display: none; +} + +#batterySoC .fill { + fill: rgb(87, 148, 242); + width: calc(86px * (var(--percentage) / 100)); + transition: width 0.3s linear 0.1s; +} + +#batterySoC.warning .fill { + fill: rgb(255, 152, 48); +} + +#batterySoC.low .fill { + fill: rgb(242, 73, 92); +} + +#batterySoC > text { + fill: #fff; + font-size: 20px; +} + +#loadText { + fill: #fff; + font-size: 70px; +} + +#loadText .sub { + font-size: 45px; +} + +#loadText .units { + font-size: 20px; +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..475d174 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/glasslabs/solar + +go 1.21.3 + +require ( + github.com/glasslabs/client-go v0.1.0 + github.com/pawal/go-hass v0.0.0-20230221123149-b1b116a7432d +) + +require ( + gopkg.in/yaml.v3 v3.0.1 // indirect + honnef.co/go/js/dom/v2 v2.0.0-20230808055721-96db8f4d5e3b // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1cc5741 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/glasslabs/client-go v0.1.0 h1:a2Ob6EMyglz+Jy53diQv62ZCBVA4/BONF3e2APcnlr0= +github.com/glasslabs/client-go v0.1.0/go.mod h1:CpO4gMLfNrbhZQsNlNjq1KcGUAk35eCWj35YBb2xccw= +github.com/pawal/go-hass v0.0.0-20230221123149-b1b116a7432d h1:8tAKssHhfrcb3zHE/EpS+p3fYUk4RLROOGoPba6/tHs= +github.com/pawal/go-hass v0.0.0-20230221123149-b1b116a7432d/go.mod h1:dEToidnncZjw4CqHXSpE0KI17uDI86Gt0Gfp5PEJKyA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/js/dom/v2 v2.0.0-20230808055721-96db8f4d5e3b h1:XOEHdukvK2DAtBpN8kQbuj6UIK5dz9DLvqc51o6w4L0= +honnef.co/go/js/dom/v2 v2.0.0-20230808055721-96db8f4d5e3b/go.mod h1:+JtEcbinwR4znM12aluJ3WjKgvhDPKPQ8hnP4YM+4jI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a3ab584 --- /dev/null +++ b/main.go @@ -0,0 +1,254 @@ +//go:build js && wasm + +package main + +import ( + _ "embed" + "fmt" + "strconv" + "strings" + "time" + + "github.com/glasslabs/client-go" + "github.com/pawal/go-hass" +) + +var ( + //go:embed assets/style.css + css []byte + + //go:embed assets/index.html + html []byte +) + +// Config is the module configuration. +type Config struct { + URL string `yaml:"url"` + Token string `yaml:"token"` + SensorIDs struct { + Load string `yaml:"load"` + PV string `yaml:"pv"` + Battery string `yaml:"battery"` + BatterySoC string `yaml:"batterySoC"` + Grid string `yaml:"grid"` + GridFrequency string `yaml:"gridFrequency"` + } `yaml:"sensorIds"` + Battery struct { + Warning int `yaml:"warning"` + Low int `yaml:"low"` + } `yaml:"battery"` + MaxWatts int `yaml:"maxWatts"` +} + +// NewConfig creates a default configuration for the module. +func NewConfig() *Config { + return &Config{} +} + +func main() { + log := client.NewLogger() + mod, err := client.NewModule() + if err != nil { + log.Error("Could not create module", "error", err.Error()) + return + } + + cfg := NewConfig() + if err = mod.ParseConfig(&cfg); err != nil { + log.Error("Could not parse config", "error", err.Error()) + return + } + + log.Info("Loading Module", "module", mod.Name()) + + m := &Module{ + mod: mod, + cfg: cfg, + log: log, + } + + if err = m.setup(); err != nil { + log.Error("Could not setup module", "error", err.Error()) + return + } + + first := true + for { + if !first { + time.Sleep(10 * time.Second) + } + first = false + + if err = m.syncStates(); err != nil { + log.Error("Could not sync states", "error", err.Error()) + continue + } + + if err = m.listenStates(); err != nil { + log.Error("Could not listen to states", "error", err.Error()) + continue + } + } +} + +// Module runs the module. +type Module struct { + mod *client.Module + cfg *Config + + ha *hass.Access + + log *client.Logger +} + +func (m *Module) setup() error { + if err := m.mod.LoadCSS(string(css)); err != nil { + return fmt.Errorf("loading css: %w", err) + } + m.mod.Element().SetInnerHTML(string(html)) + + ha := hass.NewAccess(m.cfg.URL, "") + ha.SetBearerToken(m.cfg.Token) + if err := ha.CheckAPI(); err != nil { + return fmt.Errorf("could not connect to home assistant: %w", err) + } + m.ha = ha + + return nil +} + +func (m *Module) syncStates() error { + states, err := m.ha.FilterStates("sensor") + if err != nil { + return fmt.Errorf("getting states: %w", err) + } + + for _, state := range states { + m.updateState(state.EntityID, state.State) + } + return nil +} + +func (m *Module) listenStates() error { + l, err := m.ha.ListenEvents() + if err != nil { + return fmt.Errorf("calling listen: %w", err) + } + defer func() { _ = l.Close() }() + + for { + event, err := l.NextStateChanged() + if err != nil { + return fmt.Errorf("listening for event: %w", err) + } + + if event.EventType != "state_changed" { + continue + } + if strings.TrimSuffix(strings.SplitAfter(event.Data.EntityID, ".")[0], ".") != "sensor" { + continue + } + + m.updateState(event.Data.EntityID, event.Data.NewState.State) + } +} + +const percentageVar = "--percentage: " + +func (m *Module) updateState(id, state string) { + switch id { + case m.cfg.SensorIDs.Load: + w, err := strconv.ParseInt(state, 10, 64) + if err != nil { + return + } + kw := int(w / 1000) + cw := int((w % 1000) / 10) + per := float64(w) / float64(m.cfg.MaxWatts) * 100 + perStr := strconv.FormatFloat(per, 'f', 2, 64) + + if elem := m.mod.Element().QuerySelector("#load"); elem != nil { + elem.SetAttribute("style", percentageVar+perStr) + } + if elem := m.mod.Element().QuerySelector("#loadText .super"); elem != nil { + elem.SetTextContent(strconv.Itoa(kw)) + } + if elem := m.mod.Element().QuerySelector("#loadText .sub"); elem != nil { + elem.SetTextContent("." + strconv.Itoa(cw)) + } + case m.cfg.SensorIDs.PV: + w, err := strconv.ParseInt(state, 10, 64) + if err != nil { + return + } + per := float64(w) / float64(m.cfg.MaxWatts) * 100 + perStr := strconv.FormatFloat(per, 'f', 2, 64) + + if elem := m.mod.Element().QuerySelector("#pv"); elem != nil { + elem.SetAttribute("style", percentageVar+perStr) + } + case m.cfg.SensorIDs.Battery: + w, err := strconv.ParseInt(state, 10, 64) + if err != nil { + return + } + per := float64(w) / float64(m.cfg.MaxWatts) * 100 + perStr := strconv.FormatFloat(per, 'f', 2, 64) + + if elem := m.mod.Element().QuerySelector("#battery"); elem != nil { + elem.SetAttribute("style", percentageVar+perStr) + } + case m.cfg.SensorIDs.BatterySoC: + per, err := strconv.ParseInt(state, 10, 64) + if err != nil { + return + } + perStr := strconv.Itoa(int(per)) + + var class string + if per <= int64(m.cfg.Battery.Low) { + class = "low" + } else if per <= int64(m.cfg.Battery.Warning) { + class = "warning" + } + + if elem := m.mod.Element().QuerySelector("#batterySoC"); elem != nil { + elem.SetAttribute("style", percentageVar+perStr) + elem.Class().Remove("low") + elem.Class().Remove("warning") + if class != "" { + elem.Class().Add(class) + } + } + if elem := m.mod.Element().QuerySelector("#batterySoC .value"); elem != nil { + elem.SetTextContent(perStr + "%") + } + case m.cfg.SensorIDs.Grid: + w, err := strconv.ParseInt(state, 10, 64) + if err != nil { + return + } + per := float64(w) / float64(m.cfg.MaxWatts) * 100 + perStr := strconv.FormatFloat(per, 'f', 2, 64) + + if elem := m.mod.Element().QuerySelector("#grid"); elem != nil { + elem.SetAttribute("style", percentageVar+perStr) + } + case m.cfg.SensorIDs.GridFrequency: + hz, err := strconv.ParseFloat(state, 64) + if err != nil { + return + } + + class := "off" + if hz < 10 { + class = "on" + } + + if elem := m.mod.Element().QuerySelector("#icons #grid-disconnect"); elem != nil { + elem.Class().Remove("on") + elem.Class().Remove("off") + elem.Class().Add(class) + } + } +}