{"id":49624745,"url":"https://github.com/hughobrien/breezyd","last_synced_at":"2026-05-11T09:30:01.878Z","repository":{"id":355583325,"uuid":"1228677519","full_name":"hughobrien/breezyd","owner":"hughobrien","description":"Go library, daemon, and CLI for controlling Vents Twinfresh Breezy 160 / Elite 160 Pro ductless HRVs over UDP/4000 — local-only, with Prometheus metrics","archived":false,"fork":false,"pushed_at":"2026-05-04T10:34:36.000Z","size":2045,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-04T11:31:26.979Z","etag":null,"topics":["erv","golang","heat-recovery","home-automation","hvac","iot","prometheus","reverse-engineering","twinfresh-elite-160-pro","ventilation"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hughobrien.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-04T09:00:33.000Z","updated_at":"2026-05-04T10:34:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/hughobrien/breezyd","commit_stats":null,"previous_names":["hughobrien/breezyd"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/hughobrien/breezyd","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hughobrien%2Fbreezyd","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hughobrien%2Fbreezyd/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hughobrien%2Fbreezyd/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hughobrien%2Fbreezyd/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hughobrien","download_url":"https://codeload.github.com/hughobrien/breezyd/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hughobrien%2Fbreezyd/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32636108,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-04T10:08:07.713Z","status":"online","status_checked_at":"2026-05-05T02:00:06.033Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["erv","golang","heat-recovery","home-automation","hvac","iot","prometheus","reverse-engineering","twinfresh-elite-160-pro","ventilation"],"created_at":"2026-05-05T05:01:40.459Z","updated_at":"2026-05-11T09:30:01.862Z","avatar_url":"https://github.com/hughobrien.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# breezyd\n\n[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)\n[![Go Reference](https://pkg.go.dev/badge/github.com/hughobrien/breezyd.svg)](https://pkg.go.dev/github.com/hughobrien/breezyd)\n[![Release](https://img.shields.io/github/v/release/hughobrien/breezyd)](https://github.com/hughobrien/breezyd/releases)\n\nA Go library, daemon, and CLI for controlling [Vents\nTwinfresh](https://ventilation-system.com/) Breezy ductless heat-recovery\nventilators over the local network. It speaks the device's native UDP/4000\nprotocol directly — no cloud account, no MQTT broker, no vendor app, no Home\nAssistant integration. LAN only.\n\nThe CLI works on its own — `breezy \u003cname\u003e \u003cverb\u003e` opens UDP to the\nconfigured device and exits — and that's the default for a fresh install.\nAdd the optional daemon (`breezyd`) when you want polling, caching, a JSON\nHTTP API, Prometheus `/metrics`, the embedded web dashboard, the HomeKit\nbridge to Apple Home, or to serialize writes across multiple processes\nagainst the same device.\n\n\u003e **Heads up:** the device firmware leaks its protocol password and WiFi credentials in cleartext to anyone on the LAN who knows the device ID. Put these units on an IoT VLAN. Details in [Security](#security).\n\n![breezy dashboard — three Breezy units on the LAN](tests/ui/screenshots/dashboard-3col.png)\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"tests/ui/screenshots/homekit-bridge.png\" width=\"45%\" alt=\"HomeKit bridge details in iOS Home — \u0026quot;breezyd\u0026quot; bridge with 3 accessories\" /\u003e\n  \u0026nbsp;\u0026nbsp;\n  \u003cimg src=\"tests/ui/screenshots/homekit-accessories.png\" width=\"45%\" alt=\"Each Breezy as a separate AirPurifier accessory in the Apple Home rooms list\" /\u003e\n\u003c/p\u003e\n\nThe bundled web dashboard is served from the daemon at `GET /`; it is server-rendered with `templ` + datastar and pushes updates over SSE — every connected browser sees the new state within one poll cycle, with no client-side polling. It covers power/mode/speed/heater/timer plus per-device 24-hour schedules and supports dark mode (auto via `prefers-color-scheme`, manual via a theme picker). See [Web UI](#web-ui) for details. The screenshot above is rendered automatically by `just screenshot` and re-committed when the design changes — the README always shows the current state. The two iPhone screenshots show the optional [HomeKit](#homekit) bridge: each configured Breezy appears as its own AirPurifier accessory under the auto-generated `breezyd` bridge.\n\n## At a glance\n\n```sh\n$ breezy ls\nNAME      IP                  POWER  MODE          LAST POLL\nbedroom   192.168.1.152:4000  on     supply        29s ago\noffice    192.168.1.160:4000  on     regeneration  29s ago\nplayroom  192.168.1.148:4000  off    extract       29s ago\n\n$ breezy playroom status            # sensors + fans + service info\n$ breezy bedroom speed manual:30    # set bedroom fan to 30 % manual\n$ breezy office mode regeneration   # heat-recovery mode\n$ breezy playroom faults            # active fault codes (if any)\n```\n\nv1.0 shipped the library, daemon, and CLI; v1.1 added the embedded web\ndashboard and the optional NixOS-nginx integration; v1.2 made the CLI\ndefault to standalone (the daemon is opt-in); v1.3 added the HomeKit\nbridge; v1.6 added a fleet-wide password inheritance and a NixOS\nmodule that auto-detects the daemon for every user on the host. See\n`CHANGELOG.md` for the per-version detail.\n\nWhat's covered:\n\n- Sensor metrics: humidity, eCO2, VOC, supply/extract/exhaust temperatures,\n  fan RPMs, recovery efficiency, filter remaining time, motor lifetime, RTC\n  battery, fault codes.\n- Control: power, airflow mode (ventilation / regeneration / supply / extract),\n  speed (preset 1-3 or manual 10-100 %), heater, night/turbo special-mode timer, filter timer reset, fault\n  reset, RTC set.\n- Per-device snapshots and Prometheus metrics.\n- `breezy discover` for first-time bootstrap.\n- Server-rendered web dashboard at `GET /` on the daemon (templ + datastar over SSE),\n  served from the same binary; auto-refreshes every 5 s; covers sensors,\n  fans, service info, and the four high-level controls (power / mode /\n  speed / heater). Dark mode supported (auto + manual override).\n- Daemon-driven per-device schedules: edit a small `At | Action | Pct` table from the dashboard's collapsible SCHEDULE block; the daemon fires writes on schedule with bounded retry on failure and an alert banner on persistent failure.\n- Optional HomeKit bridge: each Breezy appears in the Apple Home app\n  with power, fan speed, supply/extract switches, and the full sensor\n  surface (RH, eCO2, VOC, four temperatures).\n\nWhat's deliberately out: WiFi reconfig, MQTT bridge, Home Assistant\ncomponent. See [Known limitations](#known-limitations).\n\n### Supported devices\n\nThe same hardware ships under different model names depending on region. All\nof these report unit type `0xB9 = 17` and speak the protocol this project\nimplements:\n\n| Region        | Product name                                           |\n|---------------|--------------------------------------------------------|\n| Europe        | Vents Twinfresh **Breezy 160** (also Breezy Eco 160)   |\n| North America | Vents Twinfresh **Elite 160 Pro** (ductless HRV)       |\n\nThe vendor's smaller and larger siblings (Breezy 200, Twinfresh Elite 200 Pro,\nBreezy Eco 200) report different unit-type bytes (`20`, `22`, `24`) but use\nthe same wire protocol; this project should work against them although it has\nonly been tested against the 160 model.\n\n## Install\n\nPick the path that matches your environment. Each one ends with a working\n`breezy ls`:\n\n- **NixOS host** → [NixOS](#nixos) — 4 steps; daemon + CLI + dashboard, all module-managed.\n- **macOS or non-NixOS Linux with Nix installed** → [Nix anywhere](#nix-anywhere) — `nix profile install` lands the binaries on `$PATH`.\n- **Linux without Nix** → [Linux + systemd](#linux--systemd) — pre-built binary download + an optional hardened systemd unit.\n\n## NixOS\n\nFour steps: add the flake input, discover your devices, configure the\nmodule, rebuild and use it.\n\n### 1. Add the flake input + module import\n\n```nix\n{\n  inputs.breezyd.url = \"github:hughobrien/breezyd\";\n\n  outputs = { self, nixpkgs, breezyd, ... }: {\n    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {\n      system = \"x86_64-linux\";\n      modules = [\n        breezyd.nixosModules.default\n        ./breezyd.nix         # the host-specific config from step 3\n      ];\n    };\n  };\n}\n```\n\n### 2. Discover your devices\n\nYou need each unit's 16-character device ID before you can configure\nit. Run discovery before the module is in place — `nix run` doesn't\nneed anything installed:\n\n```sh\nnix run github:hughobrien/breezyd#breezy -- discover\n# 192.168.1.148  id=BREEZY00000000A0  type=17 (Breezy 160)\n# 192.168.1.152  id=BREEZY00000000A1  type=17 (Breezy 160)\n# 192.168.1.160  id=BREEZY00000000A2  type=17 (Breezy 160)\n```\n\nIf your devices use a non-default password, add `-p PASSWORD` (some\nfirmware drops mismatched wildcard requests despite the spec).\n\nIf discover comes back empty but the units are reachable (Wi-Fi AP\nisolation, separate VLANs, or a host firewall blocking UDP/4000 are\nthe common causes), pass each IP as a positional arg to send unicast\nwildcards instead:\n\n```sh\nnix run github:hughobrien/breezyd#breezy -- discover -p huffpuff \\\n  192.168.1.148 192.168.1.152 192.168.1.160\n```\n\nNote the IDs and IPs — both go into the next step.\n\n### 3. Configure the module\n\n```nix\n# breezyd.nix\n{\n  services.breezyd = {\n    enable = true;\n    settings = {\n      # Fleet-wide protocol password. Used for the daemon's wildcard\n      # discovery probes and inherited by any device that doesn't set\n      # its own.\n      daemon.password = \"huffpuff\";\n\n      # `ip` is optional — set it when broadcast is unreliable on your\n      # LAN, and the daemon will skip discovery for that device.\n      # Per-device `password` overrides `daemon.password`.\n      devices.bedroom  = { id = \"BREEZY00000000A0\"; ip = \"192.168.1.148\"; };\n      devices.office   = { id = \"BREEZY00000000A1\"; ip = \"192.168.1.152\"; };\n      devices.playroom = { id = \"BREEZY00000000A2\"; ip = \"192.168.1.160\"; };\n    };\n  };\n}\n```\n\nInline `settings` render into a 0600 TOML at `/run/breezyd/breezyd.toml`,\nbut anything you put there ends up readable in the world-readable Nix\nstore. For real device passwords use `services.breezyd.configFile`\nwith sops-nix or agenix to point at a secrets-managed file instead.\n\n### 4. Rebuild and use it\n\nAfter `nixos-rebuild switch`, the daemon starts, the `breezy` CLI\nlands on every user's PATH, and the module also writes a tiny\n`/etc/breezy/config.toml` with just `[daemon].listen = \"...\"` — so the\nCLI auto-detects the daemon and talks to it without anyone writing a\n`~/.config/breezy/config.toml`:\n\n```sh\n$ breezy ls\nNAME      IP                  POWER  MODE          LAST POLL\nbedroom   192.168.1.152:4000  on     supply        29s ago\noffice    192.168.1.160:4000  on     regeneration  29s ago\nplayroom  192.168.1.148:4000  off    extract       29s ago\n\n$ breezy playroom status      # full snapshot\n$ breezy bedroom speed manual:30\n$ breezy office mode regeneration\n```\n\nIf a row shows `?` for power / `never` for last poll, the daemon\nhasn't been able to reach that device yet. Check the log:\n\n```sh\njournalctl -u breezyd -n 50 | grep -E 'discovery|no IP'\n```\n\n`discovery complete found=0` means the wildcard probe didn't get any\nreplies — go back to step 2 and add `ip = \"...\"` per device, which\nbypasses discovery entirely.\n\n### What the module does\n\nCreates a `breezyd` system user, runs the daemon under systemd with\nhardening (`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp`,\n`MemoryDenyWriteExecute`, etc.), starts after `network-online.target`,\nadds the `breezy` CLI to `environment.systemPackages` so it's on\nevery user's PATH, and writes `/etc/breezy/config.toml` (mode 0644,\njust `[daemon].listen`) so the CLI auto-detects the daemon. Set\n`services.breezyd.openFirewall = true` if you bind the listener to a\nnon-loopback address.\n\n### Prometheus (optional)\n\n```nix\nservices.breezyd.prometheus.enable = true;\n# Optional tunables, defaults shown:\n# services.breezyd.prometheus.jobName = \"breezyd\";\n# services.breezyd.prometheus.scrapeInterval = \"30s\";\n```\n\nInjects an entry into `services.prometheus.scrapeConfigs` only when\nboth `services.breezyd.enable` and `services.prometheus.enable` are\ntrue.\n\n### HomeKit (optional)\n\n```nix\nservices.breezyd.homekit = {\n  enable = true;\n  port   = 51827;  # pin a fixed TCP port so the firewall hole is reachable\n};\nservices.breezyd.openFirewall = true;  # opens HAP TCP + UDP/5353 for mDNS\n# Other tunables, defaults shown:\n# services.breezyd.homekit.bridgeName = \"breezyd\";  # name shown during pairing\n# services.breezyd.homekit.stateDir   = \"/var/lib/breezyd/homekit\";\n```\n\nEach configured Breezy appears as a HomeKit accessory in Apple Home.\nThe module appends a `[homekit]` block to the generated config and\nmanages the state directory under `/var/lib/breezyd`. The pairing PIN\nis auto-generated on first start and printed in the log; reset by\ndeleting the state directory.\n\n`openFirewall` is `false` by default. When you flip it on alongside\n`homekit.enable`, the module opens both the HAP TCP port (only if\n`homekit.port != 0`) and **UDP/5353 for mDNS** — the latter is what\nlets iPhones discover the bridge on the LAN. With `port = 0` (default)\nthe OS picks an ephemeral port at start-up and the firewall can't\npre-open it, so pin a fixed `port` whenever the host firewall is on.\n\nIf you use `services.breezyd.configFile` (i.e. you manage the TOML\nyourself with sops-nix / agenix), enabling `homekit` still adjusts the\nsystemd unit (state directory, firewall) but does **not** inject a\n`[homekit]` block into your file — add it yourself.\n\n## Nix anywhere\n\nIf you have Nix installed (NixOS, nix-darwin, or any Linux/macOS\nhost with the Nix package manager), the fastest install is\n`nix profile install`. The CLI and daemon are the same derivation, so\nboth binaries land on `$PATH`:\n\n```sh\nnix profile install github:hughobrien/breezyd\nbreezy --version\n```\n\nOther entry points the flake exposes:\n\n```sh\n# Run either binary without installing — slower per-invocation because\n# `nix run` re-checks the flake every time, but useful for one-offs.\nnix run github:hughobrien/breezyd                    # daemon\nnix run github:hughobrien/breezyd#breezy -- ls       # CLI\n\n# Build standalone binaries into ./result/bin/\nnix build github:hughobrien/breezyd\n./result/bin/breezyd --version\n\n# Drop into a dev shell with Go, gopls, goreleaser, etc.\nnix develop github:hughobrien/breezyd\n```\n\nThe flake exposes three packages (`breezyd`, `breezy`, `default = breezyd`),\nthree apps (`default`, `breezyd`, `breezy`), a `devShells.default`, and a\n`nixosModules.default` for running the daemon as a NixOS service.\n\n### 1. Find your device IDs\n\n`breezy discover` broadcasts a wildcard request on UDP/4000. Each Breezy\nthat hears it answers with its 16-character device ID and unit type:\n\n```sh\nbreezy discover\n# 192.168.1.148  id=BREEZY00000000A0  type=17 (Breezy 160)\n# 192.168.1.152  id=BREEZY00000000A1  type=17 (Breezy 160)\n# 192.168.1.160  id=BREEZY00000000A2  type=17 (Breezy 160)\n```\n\nIf broadcast comes back empty but you can `ping` the units (Wi-Fi AP\nisolation, mesh hops, or separate VLANs commonly drop broadcasts while\nunicast still works), pass the IPs as positional args — the CLI will\nsend the wildcard request unicast to each:\n\n```sh\nbreezy discover 192.168.1.148 192.168.1.152 192.168.1.160\n```\n\nIf that's *still* empty and you've changed the units off the factory\npassword, retry with `-p PASSWORD`. The vendor's spec says wildcard\ndiscovery is unauthenticated, but some firmware versions silently drop\nmismatched-password requests:\n\n```sh\nbreezy discover -p testpwd 192.168.1.148 192.168.1.152 192.168.1.160\n```\n\n`-p` works with broadcast too (`breezy discover -p testpwd`), in case\nyour network is fine but only the password is the issue.\n\n### 2. Write the config\n\nCreate `~/.config/breezy/config.toml` mode `0600` with one\n`[devices.\u003cname\u003e]` block per unit:\n\n```toml\n[devices.playroom]\nid       = \"BREEZY00000000A0\"\npassword = \"testpwd\"\nip       = \"192.168.1.148\"\n\n[devices.bedroom]\nid       = \"BREEZY00000000A1\"\npassword = \"testpwd\"\nip       = \"192.168.1.152\"\n\n[devices.office]\nid       = \"BREEZY00000000A2\"\npassword = \"testpwd\"\nip       = \"192.168.1.160\"\n```\n\n```sh\nmkdir -p ~/.config/breezy\n$EDITOR ~/.config/breezy/config.toml\nchmod 0600 ~/.config/breezy/config.toml\n```\n\nThe mode-0600 check is enforced — the loader refuses to start otherwise.\n\n### 3. Verify\n\n```sh\nbreezy ls                          # all configured devices, one line each\nbreezy playroom status             # full snapshot — sensors, fans, service info\nbreezy bedroom speed manual:30     # set bedroom fan to 30 % manual\nbreezy office mode regeneration    # switch office to heat-recovery mode\n```\n\n`breezy --help` is the source of truth; see [CLI overview](#cli-overview)\nfor the full verb list.\n\n### 4. (Optional) Run the daemon\n\nRun `breezyd` if you want any of:\n\n- **Polling + caching** — every device's state is refreshed on a configurable\n  tick, served from memory. The CLI is faster and doesn't need the device\n  to be reachable for every read.\n- **JSON HTTP API** at `http://127.0.0.1:9876/v1/devices/...`.\n- **Prometheus `/metrics`** for Grafana dashboards / alerts.\n- **Embedded web dashboard** — the screenshot near the top of this README.\n- **HomeKit bridge** — see the [HomeKit](#homekit) section.\n- **Concurrency safety** when multiple processes script `breezy` against the\n  same device. Standalone CLIs don't coordinate with each other; the daemon\n  serializes per-device UDP behind a mutex.\n\nAdd a `[daemon]` block to the config and start the daemon:\n\n```toml\n[daemon]\nlisten        = \"127.0.0.1:9876\"\npoll_interval = \"30s\"\ndiscovery     = \"on-start\"   # \"on-start\" | \"off\" | \"periodic:\u003cduration\u003e\"\n```\n\n```sh\nbreezyd                            # logs to stderr; stop with SIGINT/SIGTERM\n```\n\nThe CLI auto-detects daemon mode when `[daemon].listen` is set in the\nconfig or `--daemon URL` is passed. Override with `--daemon http://...`\nto talk to a remote daemon, or omit `[daemon]` entirely to stay\nstandalone.\n\nIf the config doesn't exist when `breezyd` starts, it writes a sensible\ndefault (with `[daemon]` commented out, so re-running gets you working\nstandalone immediately) and exits with an \"edit it\" message.\n\nIf you want the daemon to run on boot under systemd, see\n[Linux + systemd](#linux--systemd) — that section's unit file works\nunchanged whether you got the binary from `nix profile install` or\nfrom a release archive.\n\n## Linux + systemd\n\nFor Ubuntu / Debian / Arch / Fedora / etc. without Nix.\n\n### 1. Download the binary\n\nPre-built binaries for Linux (amd64/arm64), macOS (amd64/arm64), and Windows\n(amd64) are published on the [GitHub Releases\npage](https://github.com/hughobrien/breezyd/releases). Download the archive\nfor your platform and extract `breezyd` and `breezy` somewhere on `$PATH`:\n\n```sh\n# Linux amd64 example\ncurl -sSL -o breezyd.tar.gz \\\n  https://github.com/hughobrien/breezyd/releases/latest/download/breezyd_Linux_x86_64.tar.gz\ntar -xzf breezyd.tar.gz breezyd breezy\nsudo install -m 0755 breezyd breezy /usr/local/bin/\nbreezyd --version\n```\n\n### 2. Find your device IDs\n\n```sh\nbreezy discover\n# 192.168.1.148  id=BREEZY00000000A0  type=17 (Breezy 160)\n# 192.168.1.152  id=BREEZY00000000A1  type=17 (Breezy 160)\n# 192.168.1.160  id=BREEZY00000000A2  type=17 (Breezy 160)\n```\n\nIf broadcast is empty, pass each IP as a positional arg, and add\n`-p PASSWORD` if your devices use a non-default password:\n\n```sh\nbreezy discover -p huffpuff 192.168.1.148 192.168.1.152 192.168.1.160\n```\n\n### 3. Standalone CLI use\n\nIf you only want the CLI (no daemon, no service), this is enough:\nwrite `~/.config/breezy/config.toml` mode 0600 with the discovered\nIDs, then run `breezy ls`. Same shape as the\n[Nix-anywhere standalone config](#2-write-the-config). Skip to step 4\nonly if you want the daemon polling in the background.\n\n### 4. (Optional) Run breezyd as a system service\n\nTwo ready-to-customize files live in [`examples/`](examples/):\n\n- [`examples/breezyd.toml`](examples/breezyd.toml) — daemon config template with `[daemon]`, three device blocks, and a commented-out `[homekit]` section.\n- [`examples/breezyd.service`](examples/breezyd.service) — systemd unit mirroring the hardening the NixOS module applies.\n\nDrop the daemon's config under `/etc/breezyd/`, create the service user, and (optionally) add a CLI fallback config so anyone on the host gets auto-detect:\n\n```sh\n# Create the daemon's user and config directory.\nsudo useradd --system --no-create-home --shell /usr/sbin/nologin breezyd\nsudo install -d -m 0750 -o breezyd -g breezyd /etc/breezyd\n\n# Copy the example config, edit for your devices, lock it down (mode 0600).\nsudo install -m 0600 -o breezyd -g breezyd examples/breezyd.toml /etc/breezyd/\nsudo $EDITOR /etc/breezyd/breezyd.toml         # set passwords + real device IDs\n\n# Optional: CLI's system fallback (no passwords). Mode 0644 — any user\n# on the host can read it; the CLI uses this when ~/.config/breezy/config.toml\n# is absent.\nsudo install -d -m 0755 /etc/breezy\nsudo tee /etc/breezy/config.toml \u003c\u003c'EOF' \u003e/dev/null\n[daemon]\nlisten = \"127.0.0.1:9876\"\nEOF\n```\n\nInstall the systemd unit:\n\n```sh\nsudo install -m 0644 examples/breezyd.service /etc/systemd/system/breezyd.service\n```\n\nThen enable and start:\n\n```sh\nsudo systemctl daemon-reload\nsudo systemctl enable --now breezyd\njournalctl -u breezyd -f               # tail the log to confirm it's polling\nbreezy ls                              # any user can talk to the daemon now\n```\n\nIf you turn on `[homekit]` in the config, also:\n\n- Add `StateDirectory=breezyd` to `[Service]` so systemd creates\n  `/var/lib/breezyd/` for the HAP server's pairing state.\n- Pin a fixed `port = N` in `[homekit]` (default `0` is ephemeral\n  and can't be firewalled), then open the host firewall:\n  `ufw allow N/tcp` (or `firewall-cmd --add-port=N/tcp`).\n- Open **UDP/5353 for mDNS** so iPhones can discover the bridge:\n  `ufw allow 5353/udp`. Without this the bridge won't appear in\n  the Add Accessory list, even though pairing would otherwise work.\n\n## Web UI\n\nThe daemon serves a server-rendered dashboard (templ + datastar over SSE) at the root path of its HTTP listener:\n\n```\nhttp://127.0.0.1:9876/\n```\n\nThree columns of cards (one per configured device) showing live sensor\nreadings (humidity / eCO₂ / VOC each with their alert threshold; clicking\na value opens an inline editor for the threshold), fan RPMs and\ncommanded percentages, service info (filter, motor lifetime, RTC\nbattery, faults), firmware version, plus controls for power, airflow\nmode, fan speed (preset 1-3 or a manual % slider), heater, and the\nnight/turbo special-mode timer. Sensor values display in red when the\nfirmware's over-threshold flag is set. The page auto-refreshes every\n5 s; cards desaturate when their last poll is more than 90 s old. Dark\nmode follows `prefers-color-scheme` automatically; click the theme icon\nnext to the title to override.\n\nThe default `[daemon].listen` is `127.0.0.1:9876`, which means the\ndashboard is reachable only from the host running `breezyd`. To use it\nfrom a phone or laptop on the same LAN, change the listener in\n`~/.config/breezy/config.toml`:\n\n```toml\n[daemon]\nlisten = \"0.0.0.0:9876\"\n```\n\n(Or pick a specific LAN IP if you want to avoid binding on every\ninterface.) Restart `breezyd` after changing the config.\n\n**Security implication:** the HTTP API has no authentication. Binding\nto a LAN-reachable address exposes every `/v1/...` endpoint to anyone\non the same network — including the raw `POST /v1/devices/\u003cname\u003e/params/\u003cid\u003e`\nwrite path that can change a unit's protocol password or WiFi\ncredentials. The mitigation is networking, not software: keep the\nunits (and the host running `breezyd`) on an IoT VLAN. See the\n[Security](#security) section for the full picture.\n\n### Behind nginx (NixOS)\n\nIf you're already running NixOS and `services.nginx`, the cleaner way\nto expose the dashboard on the LAN is the module's opt-in nginx\nintegration: keep `[daemon].listen = \"127.0.0.1:9876\"` (so the daemon\nitself stays loopback-bound and the raw API is unreachable from the\nLAN), and let nginx be the network-facing service:\n\n```nix\nservices.nginx.enable = true;\nservices.breezyd = {\n  enable = true;\n  nginx = {\n    enable = true;\n    virtualHost = \"breezy.home.lan\";\n    # basicAuthFile = \"/run/secrets/breezy-htpasswd\";  # sops-nix / agenix\n  };\n};\n\n# Define the vhost yourself — TLS, ACME, listen ports, etc. The module\n# only adds the location.\"/\" with proxy_pass + basicAuthFile.\nservices.nginx.virtualHosts.\"breezy.home.lan\" = {\n  forceSSL = true;\n  enableACME = true;\n};\n```\n\nThis is the recommended path when the dashboard is reached from\ndevices other than the host running `breezyd`. Combined with\n`basicAuthFile`, it gives you both transport-level (TLS, if you set\n`forceSSL`) and application-level (basic auth) gates that the\ndirect-listen path lacks. The daemon's full `/v1/...` API remains on\nloopback, so a compromised LAN device can't reach the raw param write\nendpoint even after authenticating to nginx.\n\n## HomeKit\n\nThe daemon includes an opt-in HomeKit bridge. When enabled, each\nconfigured Breezy appears in the Apple Home app as one accessory\nwith power, fan speed, supply-only / extract-only / heater / night /\nturbo switches, the full sensor surface (humidity, eCO2, VOC, four\ntemperatures), a filter-maintenance service with iOS's native\n\"change filter\" indicator, and a battery service for the RTC\ncoin-cell (low-battery warning at ~40 %).\n\nEnable it by adding to `~/.config/breezy/config.toml`:\n\n```toml\n[homekit]\nenabled = true\n```\n\nRestart `breezyd`. The startup log includes a line like:\n\n    homekit: bridge ready name=\"breezyd\" pin=\"123-45-678\" state_dir=\"...\"\n\nOpen the Apple Home app on iPhone → Add Accessory → enter the PIN\nmanually. All configured Breezy units appear together; each gets\nits own tile.\n\n**Reset pairing:** delete the state directory (`~/.local/state/\nbreezyd/homekit` by default, `/var/lib/breezyd/homekit` on NixOS).\nThe next daemon start regenerates the PIN.\n\n**Tunables** (all optional):\n\n- `bridge_name`: name shown during pairing. Default `\"breezyd\"`.\n- `port`: TCP port for the HAP server. Default 0 (OS-assigned).\n- `state_dir`: where pairing keys + the PIN live.\n\nOn NixOS the bridge is one knob — see [HomeKit (optional)](#homekit-optional)\nunder the NixOS section above.\n\nThe HomeKit bridge always uses the daemon path — writes go through\n`pkg/breezy/ops` with the same per-device mutex serialisation and fan-settle\nwindow as the HTTP handlers. The standalone-CLI concurrency caveat is\nunrelated; the HomeKit bridge never opens its own UDP socket.\n\n## Prometheus\n\nThe daemon exposes `/metrics` in Prometheus exposition format. Scrape it like\nany other target:\n\n```yaml\n# prometheus.yml\nscrape_configs:\n  - job_name: breezy\n    static_configs:\n      - targets: ['localhost:9876']\n```\n\nEach metric is labelled with `device=\"\u003cname\u003e\"` and `id=\"\u003c16-char id\u003e\"`. A few\nuseful queries:\n\n```promql\n# Indoor temperature per device\nbreezy_temperature_celsius{location=\"indoor\"}\n\n# Any sensor over its alert threshold (humidity / co2 / voc)\nmax by (device) (breezy_sensor_alert) \u003e 0\n\n# Recovery efficiency, room by room\nbreezy_recovery_efficiency_pct\n\n# Filter time remaining, in days\nbreezy_filter_remaining_seconds / 86400\n\n# Has any device gone unreachable in the last 5 minutes?\ntime() - breezy_last_poll_timestamp \u003e 300\n```\n\n`breezy_up{device=\"...\"}` is `1` while the poller is reaching the unit and `0`\notherwise; the corresponding `breezy_last_poll_timestamp` is the unix time of\nthe last successful read.\n\nOn NixOS the auto-scrape integration is one knob — see\n[Prometheus (optional)](#prometheus-optional) under the NixOS section.\n\n## CLI overview\n\n`breezy --help` is the source of truth. The shape is \"subject before verb\",\nso per-device commands read naturally:\n\n| Command                              | What it does                                 |\n| ------------------------------------ | -------------------------------------------- |\n| `breezy ls`                          | one-line table of every configured device   |\n| `breezy discover [-p PWD] [ip...]`   | LAN broadcast (or unicast to each IP); `-p` overrides the wildcard discovery password |\n| `breezy param`                       | list known parameters (id, type, unit, caps; use `name` with `get`/`set`) |\n| `breezy playroom status`             | full structured snapshot                     |\n| `breezy bedroom on` / `off`          | power                                        |\n| `breezy bedroom speed manual:30`     | set fan to 30 % manual                       |\n| `breezy bedroom speed 2`             | switch to preset 2                           |\n| `breezy office mode regeneration`    | airflow mode (ventilation / regeneration / supply / extract) |\n| `breezy office heater on`            | toggle the auxiliary heater                  |\n| `breezy bedroom timer night`         | start night-mode timer (or `turbo`/`off`)    |\n| `breezy playroom faults`             | list active fault codes                      |\n| `breezy playroom firmware`           | firmware version + build date                |\n| `breezy playroom efficiency`         | recovery efficiency %                        |\n| `breezy playroom rtc`                | show device clock                            |\n| `breezy playroom rtc set 2026-05-03T22:00:00-07:00` | set device clock          |\n| `breezy playroom reset-filter`       | clear the filter timer                       |\n| `breezy playroom reset-faults`       | clear active fault flags                     |\n| `breezy playroom get humidity`       | raw param read by name or hex                |\n| `breezy playroom set 0x25 1e`        | raw param write (hex)                        |\n\nThe CLI exit codes are: `0` success, `1` backend error (HTTP envelope in daemon\nmode, plain error message in standalone mode), `2` local usage error.\n\n### Standalone vs daemon mode\n\nThe CLI defaults to standalone mode (UDP directly to each device). The\ntypical flow is in [Nix anywhere](#nix-anywhere) for Nix users and\n[Linux + systemd](#linux--systemd) for everyone else; both end with a\nworking `breezy ls` either with or without the daemon running.\n\n**Concurrency caveat:** the daemon serializes per-device UDP behind a\nmutex. Standalone CLI processes do not coordinate with each other —\ntwo `breezy` invocations against the same device at the same instant\ncan produce silent checksum corruption. If you script invocations in\nparallel against the same device, run the daemon and use the CLI in\ndaemon mode.\n\n## Configuration reference\n\n`~/.config/breezy/config.toml` — mode `0600` (loader enforces when the file\ncontains passwords). Both daemon and CLI read this file. The CLI uses\n`[daemon].listen` to decide whether to talk HTTP to a daemon or UDP directly\nto each device, and reads each `[devices.\u003cname\u003e]` for standalone-mode unicast\ntargets.\n\nThe CLI also tries `/etc/breezy/config.toml` as a fallback when no\nhome-directory config exists. The system fallback is typically just\n`[daemon].listen = \"...\"` (no passwords), which the loader accepts at\nmode 0644 so every user on the host can read it. The NixOS module\nwrites this file automatically when `services.breezyd.enable = true`.\n\nFull schema with defaults:\n\n```toml\n# Optional. Without this block the CLI runs in standalone mode (no HTTP).\n[daemon]\nlisten        = \"127.0.0.1:9876\"   # http listener; required when [daemon] present\npoll_interval = \"30s\"              # default 30s\ndiscovery     = \"on-start\"         # \"on-start\" | \"off\" | \"periodic:\u003cduration\u003e\"\npassword      = \"\"                 # optional fleet-wide protocol password; used for\n                                   # the daemon's discovery probes and inherited by\n                                   # any [devices.\u003cname\u003e] block that omits its own\n\n# Optional. Off by default. See HomeKit section.\n[homekit]\nenabled = false\n# bridge_name = \"breezyd\"\n# port = 0                          # 0 = ephemeral\n# state_dir = \"~/.local/state/breezyd/homekit\"\n\n# One [devices.\u003cname\u003e] block per Breezy unit. Name = the label used as the\n# CLI's \u003csubject\u003e: \"breezy playroom status\".\n[devices.playroom]\nid       = \"BREEZY00000000A0\"      # 16-char device ID; from `breezy discover`\npassword = \"testpwd\"               # protocol password; falls back to [daemon].password if absent\nip       = \"192.168.1.148\"         # required in standalone; optional in daemon mode\n```\n\nIf you'd rather keep the config elsewhere (e.g. sops-nix / agenix), point\nthe daemon at it with `--config /path/to/file`. The mode-0600 check still\napplies whenever the file contains passwords. The CLI's config path order\nis fixed at `~/.config/breezy/config.toml` then `/etc/breezy/config.toml`.\n\n## Security\n\nThe Breezy firmware will hand out its own protocol password (param `0x7D`),\nthe WiFi SSID (`0x95`), and the WiFi password (`0x96`) over UDP/4000 in\ncleartext, to any client on the same broadcast domain that knows the\n16-character device ID. Discovery is itself unauthenticated — anyone on\nthe LAN can enumerate every Breezy unit and read those parameters.\n\nMitigation is networking, not software: put the units on an IoT VLAN that\ncannot reach the rest of your home LAN, and only allow the host running\n`breezyd` into that VLAN. This project does not add cryptography on top of\nthe wire protocol — that would not change the threat model, since the\ndevice firmware itself answers in cleartext.\n\nThe web dashboard at `GET /` lives on the same listener as the JSON API.\nIf you change `[daemon].listen` from the loopback default to a LAN\naddress so the dashboard is reachable from your phone, you also expose\nthe rest of the API — including raw parameter writes — to anyone on\nthat network. The same VLAN-segmentation recommendation applies: put\nthe host running `breezyd` on the IoT VLAN with the units, and reach\nthe dashboard from a workstation that is briefly granted access to\nthat VLAN, rather than binding `breezyd` to your trusted LAN.\n\n## Known limitations\n\nThese are deliberate omissions, not bugs. Each is a design choice; see the\nspec for the full rationale.\n\n- **No WiFi reconfig.** Changing the WiFi SSID/password from the CLI is\n  technically possible but operationally hazardous (one bad write strands the\n  unit). Use the vendor app for this.\n- **No MQTT bridge.** The HTTP API and Prometheus surface cover every use\n  case the operator has so far. The state cache is shaped so a bridge could\n  be added later without rewriting the core.\n- **No Home Assistant integration.** Same reasoning. Anyone who wants HA\n  integration can build a REST sensor on top of `/v1/devices/\u003cname\u003e` or\n  scrape `/metrics`.\n\n## Developing\n\nWorking on breezyd itself? Start here.\n\n### Build from source\n\nRequires Go 1.22+ (developed on 1.26) and the `templ` CLI. No other system\ndependencies for the binaries themselves; the race-detector recipe\n(`just test-race`) needs a working C toolchain.\n\n`nix develop` provides all prerequisites including `templ`. Outside Nix:\n\n```sh\ngo install github.com/a-h/templ/cmd/templ@v0.3.x\n```\n\n```sh\njust generate    # run templ codegen (needed once after checkout or .templ edits)\njust build       # generate + produces ./breezyd and ./breezy\njust check       # vet + fast tests + templ-drift (pre-commit gate)\njust test-race   # full race-detector run (the CI command)\n```\n\n`just test-race` already sets `CGO_ENABLED=1 CC=clang`, so the recipe works\nout of the box on dev hosts whose default `gcc` lacks the TSan runtime.\n\n### Project layout\n\n```\nbreezyd/\n├── pkg/breezy/                # protocol library (importable)\n│   ├── frame.go               # FDFD/02 packet codec\n│   ├── client.go              # UDP transport, retries, timeouts\n│   ├── params.go              # parameter registry (id, type, R/W, units)\n│   ├── values.go              # typed value codecs\n│   ├── discover.go            # LAN broadcast\n│   └── fakedevice/            # in-process protocol-speaking fake for tests\n├── cmd/breezyd/               # the daemon (HTTP + Prometheus + poller)\n│   └── ui/                    # templ templates, datastar+dashboard vendor JS, style.css, view.go\n├── cmd/breezy/                # the CLI (standalone UDP by default; daemon mode opt-in)\n├── cmd/fakedevice/            # build-tagged fakedevice binary with admin HTTP (for Playwright)\n├── internal/config/           # TOML config loader, shared by both\n├── tools/                     # Phase 0 Python probes (one-off, kept for reference)\n└── docs/superpowers/specs/    # design doc, parameter map, vendor PDF manual\n```\n\n### Testing\n\n```sh\njust test                       # unit tests (uses fakedevice)\njust test-race                  # same, with -race (the CI command)\njust lint                       # go vet + gofmt-drift check\njust check                      # lint + fast tests (pre-commit gate)\njust check-all                  # check + test-race + Playwright UI suite\n```\n\nUI tests are end-to-end Playwright specs under `tests/ui/` that spawn a real\n`breezyd` process pointed at `cmd/fakedevice` (a build-tagged UDP fake with an\nHTTP admin control plane):\n\n```sh\njust test-ui-install            # one-time: pnpm install + chromium download\njust test-ui                    # 82 tests (66 active + 16 fixme), ~20 s\njust screenshot                 # re-render tests/ui/screenshots/*.png\n```\n\nRun a single Go package or test with raw `go`:\n\n```sh\ngo test ./pkg/breezy/...\ngo test ./cmd/breezyd -run TestPoller_FanSettle\n```\n\nLive integration tests against real hardware are gated by both the\n`integration` build tag and `BREEZY_INTEGRATION=1`, plus three env vars\nidentifying the target device. The `just test-integration` recipe wraps\nall of that:\n\n```sh\njust test-integration 192.168.1.148 BREEZY00000000A0 \u003cyour password\u003e\n```\n\nThese tests write to the device — each one registers a `t.Cleanup` that\nrestores the prior value, so re-runs leave the unit in its original state.\n\n### Pointers to deeper docs\n\n- `docs/superpowers/specs/2026-05-03-twinfresh-cli-design.md` — full v1 design\n  doc: protocol decisions, daemon architecture, error semantics, status-line\n  format, etc.\n- `docs/superpowers/specs/2026-05-04-basic-ui-design.md` — design doc for the\n  web dashboard, the bind-address tradeoff, and the optional NixOS-nginx\n  reverse-proxy integration.\n- `docs/superpowers/specs/2026-05-04-discover-investigation.md` — the two\n  causes behind `breezy discover` failures (a code defect, fixed; and the\n  QEMU-NAT environmental constraint, documented) with concrete next steps.\n- `docs/superpowers/specs/2026-05-03-param-map.md` — every parameter ID the\n  device exposes, with type, units, observed values, and notes from Phase 0\n  characterization.\n- `docs/superpowers/specs/breezy-manual-vendor.pdf` — vendor protocol manual,\n  the authoritative reference for the wire protocol. Cached locally for offline\n  reading; the canonical copy is published by Vents at\n  \u003chttps://ventilation-system.com/download/breezy-manual-21433.pdf\u003e.\n- `docs/superpowers/specs/breezy-datasheet-vendor.pdf` — hardware datasheet.\n  Canonical copy at\n  \u003chttps://ventilation-system.com/download/breezy-datasheet-21437.pdf\u003e.\n\n## Credits\n\nThis project would not have been possible without the published protocol\ndocumentation from **Ventilation Systems Ltd. (Vents)**. The Breezy / Breezy\nEco connection-instruction manual at\n\u003chttps://ventilation-system.com/download/breezy-manual-21433.pdf\u003e documents the\nfull wire protocol, packet structure, function codes, and parameter table that\nthis library implements. Reading the manual confirmed (and in places\ncorrected) the empirical reverse-engineering captured during Phase 0 of this\nproject. Thanks to Vents for publishing it openly.\n\nThe bundled copies of the manual and datasheet under\n`docs/superpowers/specs/` are provided for convenience and remain © Vents.\nRefer to the canonical URLs above for the latest versions.\n\n## License\n\nCopyright (C) 2026 Hugh O'Brien\n\nThis program is free software: you can redistribute it and/or modify it under\nthe terms of the GNU General Public License as published by the Free Software\nFoundation, either version 3 of the License, or (at your option) any later\nversion (`SPDX-License-Identifier: GPL-3.0-or-later`).\n\nThis program is distributed in the hope that it will be useful, but WITHOUT\nANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS\nFOR A PARTICULAR PURPOSE. See the [LICENSE](LICENSE) file for the full text\nof the GNU General Public License v3.\n\nThis project is not affiliated with or endorsed by Ventilation Systems Ltd.\n\"Vents\" and \"Twinfresh\" are trademarks of their respective owners.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhughobrien%2Fbreezyd","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhughobrien%2Fbreezyd","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhughobrien%2Fbreezyd/lists"}