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..9161404 --- /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.22" + + 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..b5b5ad4 --- /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.22" + 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..cf7fdb7 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,36 @@ +version: 2 +project_name: water +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..2896dc6 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +![Logo](http://svg.wiersma.co.za/glasslabs/module?title=Water&tag=a%02water%20tracking%20module) + +Water tracking module for [looking glass](http://github.com/glasslabs/looking-glass) + +## Usage + +```yaml +modules: + - name: simple-water + url: https://github.com/glasslabs/water/releases/download/v1.0.0/water.wasm + position: top:right + config: + url: http://my-hass-instance:8123 + token: <your-hass-token> + sensorIds: + geyserPct: sensor.geyser_hot_water + tankPct: sensor.reservoir_percentage + geyser: + warning: 50 + low: 30 + tank: + warning: 50 + low: 30 +``` + +## Configuration + +### Geyser Percentage Sensor ID (sensorIds.geyserPct) + +The Home Assistant geyser percentage sensor ID. + +### Tank Percentage Sensor ID (sensorIds.tankPct) + +The Home Assistant water tank percentage sensor ID. + +### Geyser Warning Percentage (geyser.warning) + +The Geyser percentage for the hot water bar to display in warning style. + +### Geyser Low Percentage (geyser.low) + +The Geyser percentage for the hot water bar to display in low style. + +### Tank Warning Percentage (tank.warning) + +The Tank percentage for the tank bar to display in warning style. + +### Tank Low Percentage (tank.low) + +The Tank percentage for the tank bar to display in low style. diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..cba643c --- /dev/null +++ b/assets/index.html @@ -0,0 +1,31 @@ +<html> + <head> + <style> + body { + background: #000; + } + </style> + <link rel="stylesheet" href="style.css"> + </head> +<body> + +<svg viewBox="0 0 250 250" width="250" height="250"> + <g id="geyser"> + <circle r="100" cx="125" cy="125" transform="rotate(123,125,125)" + fill="none" stroke="rgb(40,40,40)" stroke-width="20" stroke-dasharray="510.82 785.40"/> + <circle id="heat" r="100" cx="125" cy="125" class="geyser" style="--percentage: 0"/> + </g> + + <g id="tank"> + <circle r="100" cx="125" cy="125" transform="rotate(58,125,125)" + fill="none" stroke="rgb(30,30,30)" stroke-width="15" stroke-dasharray="109.82 785.40"/> + <circle id="water" r="100" cx="125" cy="125" transform="rotate(118,125,125)" class="tank" style="--percentage: 0"/> + </g> + + <text id="geyserText" x="50%" y="50%" class="number" dominant-baseline="middle" text-anchor="middle"> + <tspan class="super"></tspan> + <tspan class="units" dx="-10" dy="6">%</tspan> + </text> +</svg> + </body> +</html> \ No newline at end of file diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..32cbcf3 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,58 @@ +.geyser { + r: var(--radius); + transform: rotate(129deg); + transform-origin: center center; + fill: none; + stroke-width: 5; + stroke-dasharray: calc(2 * pi * var(--radius)); + stroke-dashoffset: calc(2 * pi * var(--radius) * (1 - ((var(--percentage) / 100) * 0.78))); + transition: stroke-dashoffset 0.3s linear 0.1s; +} + +#heat.geyser { + --radius: 100; + stroke: rgb(87, 148, 242); +} + +#heat.geyser.warm { + stroke: rgb(255, 152, 48); +} + +#heat.geyser.hot { + stroke: rgb(242, 73, 92); +} + +.tank { + r: var(--radius); + fill: none; + stroke-width: 5; + stroke-dasharray: calc(2 * pi * var(--radius)); + stroke-dashoffset: calc(2 * pi * var(--radius) * (1 - ((var(--percentage) / 100) * -0.16))); + transition: stroke-dashoffset 0.3s linear 0.1s; +} + +#water.tank { + --radius: 100; + stroke: rgb(55 199 255); +} + +#water.tank.medium { + stroke: rgb(255, 152, 48); +} + +#water.tank.low { + stroke: rgb(242, 73, 92); +} + +#geyserText { + fill: #fff; + font-size: 60px; +} + +#geyserText .sub { + font-size: 45px; +} + +#geyserText .units { + font-size: 20px; +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..815e11b --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/glasslabs/water + +go 1.22 + +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..8c59f39 --- /dev/null +++ b/main.go @@ -0,0 +1,186 @@ +//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 { + GeyserPct string `yaml:"geyserPct"` + TankPct string `yaml:"tankPct"` + } `yaml:"sensorIds"` + Geyser struct { + Warning int `yaml:"warning"` + Low int `yaml:"low"` + } `yaml:"geyser"` + Tank struct { + Warning int `yaml:"warning"` + Low int `yaml:"low"` + } `yaml:"tank"` +} + +// 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.GeyserPct: + per, err := strconv.ParseFloat(state, 64) + if err != nil { + return + } + if per > 100 { + per = 100 + } + perStr := strconv.FormatFloat(per, 'f', 0, 64) + + if elem := m.mod.Element().QuerySelector("#heat"); elem != nil { + elem.SetAttribute("style", percentageVar+perStr) + } + if elem := m.mod.Element().QuerySelector("#geyserText .super"); elem != nil { + elem.SetTextContent(strconv.Itoa(int(per))) + } + case m.cfg.SensorIDs.TankPct: + per, err := strconv.ParseFloat(state, 64) + if err != nil { + return + } + perStr := strconv.FormatFloat(per, 'f', 2, 64) + + if elem := m.mod.Element().QuerySelector("#water"); elem != nil { + elem.SetAttribute("style", percentageVar+perStr) + } + } +}