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)
+		}
+	}
+}