From ea2cbc13f320552e84cf9397ac9ed933600846bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 20:41:33 +0000 Subject: [PATCH 1/3] feat(ha): first-party Home Assistant add-on (deploy/homeassistant) Rebased onto current master (v1.4.0 / srcfl/ftw structure). The community add-on (erikarenhill/ha-addon-forty-two-watts) pinned ghcr.io/frahlg/forty-two-watts:v1.3.0 and stopped tracking releases after the Sourceful restructure: - Canonical image is now ghcr.io/srcfl/ftw; the frahlg path survives only as a byte-identical mirror (same manifest digest). - Runtime binary is /app/ftw; transparent to the wrapper (CMD overrides flags only, inherits ENTRYPOINT; the -config/-web/-drivers/-user-drivers interface is unchanged). - The MPC optimizer runs as a separate ftw-optimizer sidecar container -- the one restructure a single-container add-on can't mirror. Adds deploy/homeassistant/ (repository.yaml + ftw/ add-on): a rebase wrapper targeting srcfl/ftw:v1.4.0 onto Supervisor's /data, restoring the -user-drivers overlay, host networking, no ingress, self-update off. Version 1.4.0 matches the release line (package.json / release / compose), so the earlier semver-inversion concern does not apply. docs/ha-addon.md carries the full findings, storage-growth guidance (Parquet cold history ~a few GB/year, included in HA backups), and the open-items checklist. README gains a Home Assistant add-on install section. Co-Authored-By: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com> Co-Authored-By: Erik Arenhill <758402+erikarenhill@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01HBjLafzpEYX1dptEE7AGsx --- .changeset/ha-addon-first-party.md | 22 ++++ .gitignore | 3 + README.md | 8 ++ deploy/homeassistant/README.md | 45 ++++++++ deploy/homeassistant/ftw/DOCS.md | 77 +++++++++++++ deploy/homeassistant/ftw/Dockerfile | 31 ++++++ deploy/homeassistant/ftw/README.md | 10 ++ deploy/homeassistant/ftw/build.yaml | 19 ++++ deploy/homeassistant/ftw/config.yaml | 58 ++++++++++ deploy/homeassistant/repository.yaml | 17 +++ docs/ha-addon.md | 160 +++++++++++++++++++++++++++ 11 files changed, 450 insertions(+) create mode 100644 .changeset/ha-addon-first-party.md create mode 100644 deploy/homeassistant/README.md create mode 100644 deploy/homeassistant/ftw/DOCS.md create mode 100644 deploy/homeassistant/ftw/Dockerfile create mode 100644 deploy/homeassistant/ftw/README.md create mode 100644 deploy/homeassistant/ftw/build.yaml create mode 100644 deploy/homeassistant/ftw/config.yaml create mode 100644 deploy/homeassistant/repository.yaml create mode 100644 docs/ha-addon.md diff --git a/.changeset/ha-addon-first-party.md b/.changeset/ha-addon-first-party.md new file mode 100644 index 00000000..c564b35f --- /dev/null +++ b/.changeset/ha-addon-first-party.md @@ -0,0 +1,22 @@ +--- +"ftw": minor +--- + +Add a first-party Home Assistant OS / Supervised add-on under +`deploy/homeassistant/`, versioned in lockstep with releases, replacing the +stale community add-on (`erikarenhill/ha-addon-forty-two-watts`) that was +pinned to `ghcr.io/frahlg/forty-two-watts:v1.3.0`. + +The project was restructured under Sourceful: the canonical image is now +`ghcr.io/srcfl/ftw` (the old path survives only as a byte-identical mirror), +the runtime binary is `/app/ftw`, and the MPC optimizer runs as a separate +`ftw-optimizer` sidecar. A hand-maintained external wrapper can't follow that +drift. + +The in-tree add-on targets the canonical `srcfl/ftw` (pinned `v1.4.0`), +rebases it onto Supervisor's `/data` volume, restores the `-user-drivers` +overlay so hot-reload Lua drivers persist across updates, and uses host +networking (Modbus TCP / LAN MQTT / mDNS). README gains an install section; +the full analysis, storage-growth guidance, and remaining packaging +limitations (optimizer sidecar in a single-container add-on, `/data` sizing, +distribution/mirror automation) are in `docs/ha-addon.md`. diff --git a/.gitignore b/.gitignore index 6e9aeac2..fdf2f40b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /release *.redb config.yaml +# The Home Assistant add-on manifest is literally named config.yaml but is +# source, not user state — keep it tracked. +!deploy/homeassistant/ftw/config.yaml data/ # Go build artifacts diff --git a/README.md b/README.md index d0436a80..39178d80 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ The dashboard is intentionally local. Use a VPN or another operator-managed private network when access is needed away from home; FTW does not ship a public relay. +## Install as a Home Assistant add-on + +For Home Assistant OS / Supervised, a first-party add-on lives in +[`deploy/homeassistant/`](deploy/homeassistant/). It wraps the released +`ghcr.io/srcfl/ftw` image onto Supervisor's `/data` volume and is versioned in +lockstep with releases. Setup, the optimizer/storage caveats, and the +publishing plan are in [`docs/ha-addon.md`](docs/ha-addon.md). + ## Local development Requirements are Go, Python 3 and Node.js. The optimizer environment is cached diff --git a/deploy/homeassistant/README.md b/deploy/homeassistant/README.md new file mode 100644 index 00000000..455636fe --- /dev/null +++ b/deploy/homeassistant/README.md @@ -0,0 +1,45 @@ +# FTW — Home Assistant add-on repository + +This directory is a self-contained [Home Assistant add-on +repository](https://developers.home-assistant.io/docs/add-ons/repository) for +**FTW**, kept inside the main repo so it stays in lockstep with the code it +wraps. + +``` +deploy/homeassistant/ +├── repository.yaml # add-on repository descriptor +└── ftw/ + ├── config.yaml # add-on manifest (arch, ports, host_network, map) + ├── build.yaml # per-arch base image (pinned upstream tag) + ├── Dockerfile # rebases upstream image onto Supervisor's /data + ├── DOCS.md # user-facing docs shown in the HA UI + └── README.md # this add-on's short description +``` + +## Why it lives here + +The previous community add-on +([`erikarenhill/ha-addon-forty-two-watts`](https://github.com/erikarenhill/ha-addon-forty-two-watts)) +pinned `ghcr.io/frahlg/forty-two-watts:v1.3.0` and stopped tracking releases. +The project was restructured under Sourceful: the canonical image is now +`ghcr.io/srcfl/ftw` (the old path survives only as a byte-identical mirror), +the binary was renamed `/app/ftw`, and the optimizer runs as a separate +`ftw-optimizer` sidecar container. A hand-maintained external wrapper drifts +against those changes. + +Full analysis — what still works, the optimizer single-container limitation, +storage growth, and the distribution plan — is in +[`docs/ha-addon.md`](../../docs/ha-addon.md). Versioning the add-on next to the +code lets each release bump the pinned image tag and the add-on `version:` in +the same commit. + +## Consuming it + +A HA add-on repository must have `repository.yaml` at its **git root**, so the +Supervisor cannot add the monorepo root directly. Either: + +- **Publish/mirror** this `deploy/homeassistant/` subtree to a dedicated + add-on repo (e.g. `srcfl/ha-addons`) — ideally from CI on each release — and + point users at that repo URL; or +- **Local test:** copy `deploy/homeassistant/` onto the HA host under + `/addons` (Supervised) or `/root/addons`, then reload local add-ons. diff --git a/deploy/homeassistant/ftw/DOCS.md b/deploy/homeassistant/ftw/DOCS.md new file mode 100644 index 00000000..f273a492 --- /dev/null +++ b/deploy/homeassistant/ftw/DOCS.md @@ -0,0 +1,77 @@ +# FTW + +Local-first home energy coordination — model-predictive battery dispatch, +PV / load / price planning, and hot-reloadable Lua device drivers — running +as a Home Assistant OS / Supervised add-on. + +## Installation + +1. Add this add-on repository to Home Assistant (**Settings → Add-ons → + Add-on Store → ⋮ → Repositories**), then install **FTW**. +2. Start the add-on. First boot has no configuration yet, so the app comes + up in its setup wizard. +3. Open the web UI (**Open Web UI**, or `http://:8080/setup`) + and complete onboarding: pick your drivers, scan the LAN for devices, and + save. The wizard writes `/data/config.yaml`. + +## Configuration + +Almost all configuration happens **in the web UI**, not in the add-on +Options tab. The setup wizard and the running dashboard read and write +`/data/config.yaml`, which persists across add-on updates and restarts. +There are no add-on options to set today. + +### Networking + +The add-on uses **host networking**, required for its core function: + +- **Modbus TCP** to inverters and meters on your LAN +- **MQTT** brokers on your LAN, including the Home Assistant **Mosquitto + broker** add-on (reachable at `core-mosquitto:1883`) +- **mDNS / broadcast discovery** (e.g. Sourceful Zap at `zap.local`) + +Because of host networking, the UI is served directly on **port 8080** of the +Home Assistant host (not via Ingress). If something else already uses port +8080 on that host, the add-on will fail to bind. + +### Optimizer + +Upstream runs the long-horizon (CVXPY) optimizer as a **separate +`ftw-optimizer` container**. A Home Assistant add-on is single-container, so +that sidecar is not bundled here and the app runs with its built-in / Go +fallback planning. Core safety and dispatch are unaffected; advanced +long-horizon optimization may be reduced. + +### Adding your own Lua drivers + +Drop hot-reloadable `*.lua` files into **`/data/drivers`** (via the Samba/SSH +add-ons or a file editor). They overlay the bundled drivers and survive add-on +updates. With the `share` mapping you can also keep them under `/share`. + +## Data, persistence & sizing + +Everything the app keeps — `config.yaml`, `state.db`, trained battery models, +and the `cold/` Parquet history — lives in the add-on's `/data` volume and is +preserved across updates. + +**Please read on SD-card installs.** FTW is a time-series system; its cold +Parquet history grows roughly **a few GB per year** (50 metrics), on top of +`state.db`. That data is on the **shared Home Assistant data partition**, and +Home Assistant **includes add-on `/data` in full backups** — so an unattended +install can bloat both the partition and every backup. Recommendations: + +- Prefer an **SSD / large eMMC** over a small SD card. +- Point the app's `state.cold_dir` at `/share` (or a mounted data disk) so + history isn't on the OS partition. +- **Exclude this add-on's data** from routine Home Assistant backups unless + you want the full history in them. + +## Updates + +Updates are handled by Home Assistant's Supervisor. The app's own in-app +self-update feature (the standalone Docker deploy's Update/Restart buttons) is +not part of this add-on — Supervisor owns the lifecycle here. + +## Support + +Issues and questions: . diff --git a/deploy/homeassistant/ftw/Dockerfile b/deploy/homeassistant/ftw/Dockerfile new file mode 100644 index 00000000..410977af --- /dev/null +++ b/deploy/homeassistant/ftw/Dockerfile @@ -0,0 +1,31 @@ +# Wraps the upstream multi-arch FTW image so it runs against Home Assistant +# Supervisor's /data volume instead of the image-native /app/data. Supervisor +# builds this locally on install using build.yaml's per-arch `build_from` +# (injected as BUILD_FROM); no add-on-side CI needed. +# +# Why a wrapper and not a plain `image:` reference in config.yaml: +# the upstream image bakes in WORKDIR /app/data, VOLUME /app/data, a non-root +# USER (100:101), a HEALTHCHECK, and a CMD that hardcodes /app/data paths. HA +# add-ons persist /data (owned by root) and cannot override CMD on a prebuilt +# `image:`. Rebasing here lets us repoint all of that at /data in one place. +# +# The upstream ENTRYPOINT is /app/ftw. Because we override CMD with FLAGS ONLY +# (not the binary path) and inherit ENTRYPOINT, the wrapper is unaffected by +# the binary name — the -config/-web/-drivers/-user-drivers flag interface is +# what matters and is unchanged. +ARG BUILD_FROM +# hadolint ignore=DL3006 +FROM ${BUILD_FROM} + +# Supervisor owns /data as root; run as root so the app can create +# config.yaml + state.db there. (The upstream image uses uid 100 for the +# standalone docker-compose deploy, which is not how add-ons persist.) +USER root +WORKDIR /data + +# The app resolves relative state paths (state.db, cold/) against the CWD, so +# WORKDIR /data is load-bearing — it puts persistent state on the add-on +# volume. -user-drivers /data/drivers keeps the /app/drivers image bundle +# immutable while letting operators drop hot-reloadable Lua under +# /data/drivers, which survives add-on updates. Mirrors the upstream CMD. +CMD ["-config", "/data/config.yaml", "-web", "/app/web", "-drivers", "/app/drivers", "-user-drivers", "/data/drivers"] diff --git a/deploy/homeassistant/ftw/README.md b/deploy/homeassistant/ftw/README.md new file mode 100644 index 00000000..d4e3cfa5 --- /dev/null +++ b/deploy/homeassistant/ftw/README.md @@ -0,0 +1,10 @@ +# FTW + +Local-first home energy coordination — MPC battery dispatch, PV / load / +price planning, and hot-reloadable Lua device drivers — as a Home Assistant +add-on. + +Configuration happens in the built-in web setup wizard on first boot. See +[DOCS.md](DOCS.md) for installation, networking, and the storage-sizing +caveat, and the [project repository](https://github.com/srcfl/ftw) for full +documentation. diff --git a/deploy/homeassistant/ftw/build.yaml b/deploy/homeassistant/ftw/build.yaml new file mode 100644 index 00000000..8fa726a0 --- /dev/null +++ b/deploy/homeassistant/ftw/build.yaml @@ -0,0 +1,19 @@ +# Per-arch base image for the local Supervisor build. +# +# Pin the canonical Sourceful image `ghcr.io/srcfl/ftw`. It is a single +# multi-arch manifest (linux/amd64 + linux/arm64), so the SAME tag serves +# both HA architectures — Docker selects the layer matching the build +# platform. +# +# Pin a concrete release tag (never :latest) so the build is reproducible and +# Supervisor's `version:` maps to exactly one image. Bump this together with +# config.yaml `version:` on every release. GHCR also carries unrelated +# edge/beta and legacy-mirror tags — ignore those; track the release line +# (package.json), currently v1.4.0. +build_from: + amd64: ghcr.io/srcfl/ftw:v1.4.0 + aarch64: ghcr.io/srcfl/ftw:v1.4.0 + +labels: + org.opencontainers.image.title: "FTW (Home Assistant add-on)" + org.opencontainers.image.source: "https://github.com/srcfl/ftw" diff --git a/deploy/homeassistant/ftw/config.yaml b/deploy/homeassistant/ftw/config.yaml new file mode 100644 index 00000000..0855312b --- /dev/null +++ b/deploy/homeassistant/ftw/config.yaml @@ -0,0 +1,58 @@ +# FTW — Home Assistant add-on manifest. +# +# Keep `version` in sync with the pinned base image tag in build.yaml and +# with the repo's package.json version (both track the release line, e.g. +# 1.4.0). Home Assistant Supervisor orders add-on updates by semver on this +# `version`, so it must follow the same line as the published image tags. +name: FTW +version: "1.4.0" +slug: ftw +description: >- + Local-first home energy coordination — MPC battery dispatch, PV / load / + price planning, and hot-reloadable Lua device drivers. +url: https://github.com/srcfl/ftw/tree/master/deploy/homeassistant/ftw +arch: + - amd64 + - aarch64 +init: false +startup: application +boot: auto + +# Host networking is required, not optional, for the core value of the app: +# - Modbus TCP to inverters / meters on the LAN +# - MQTT brokers on the LAN (incl. the HA Mosquitto add-on at core-mosquitto) +# - mDNS / broadcast driver discovery (e.g. Sourceful Zap at zap.local) +# Ingress is intentionally NOT enabled: the app is not X-Ingress-Path aware, +# and host networking + ingress do not compose cleanly. Access is via the +# direct port below. +host_network: true + +ports: + "8080/tcp": 8080 +ports_description: + "8080/tcp": Web UI / HTTP API +webui: "http://[HOST]:[PORT:8080]/" + +# Persistent state — config.yaml, state.db, battery models, cold/ Parquet — +# lives in the add-on's /data volume and survives add-on updates. See the +# wrapper Dockerfile for how /data is wired to the app, and DOCS.md for the +# storage-growth caveat (the cold Parquet history grows ~a few GB/year and +# is included in HA backups). +# +# share:rw lets operators keep hot-reloadable Lua drivers under /share, or +# point the app's state.cold_dir there to keep history off the OS partition. +map: + - share:rw + +# Configuration is done in the built-in web setup wizard +# (http://:8080/setup on first boot), which writes /data/config.yaml. +# No options schema yet — wiring HA options.json through to the app's YAML +# config is tracked as follow-up in docs/ha-addon.md. +# +# KNOWN LIMITATION (see docs/ha-addon.md): upstream runs the MPC optimizer as +# a separate `ftw-optimizer` sidecar container. A HA add-on is single- +# container, so that sidecar is not present here; the app runs with its +# in-process / auto optimizer transport. Full optimizer parity needs either +# graceful degradation in the app or bundling the optimizer via s6. +options: {} +schema: {} diff --git a/deploy/homeassistant/repository.yaml b/deploy/homeassistant/repository.yaml new file mode 100644 index 00000000..6eb5ef37 --- /dev/null +++ b/deploy/homeassistant/repository.yaml @@ -0,0 +1,17 @@ +# Home Assistant add-on repository descriptor. +# +# This directory (deploy/homeassistant/) is a self-contained Home Assistant +# add-on repository: a repository.yaml at the root plus one directory per +# add-on. It lives inside the main FTW repo so the add-on is versioned in +# lockstep with the code it wraps — every release can bump the pinned image +# tag and the add-on `version:` in the same commit, instead of the add-on +# drifting in a separate, hand-maintained repo. +# +# Distribution note: a HA add-on repository must have repository.yaml at its +# GIT ROOT, so the Supervisor cannot add the monorepo root URL directly. +# Either publish/mirror this subtree to a dedicated repo (see docs/ha-addon.md) +# or, for local testing, copy deploy/homeassistant/ onto the HA host under +# /addons (Supervised) or /root/addons. +name: FTW Add-ons +url: https://github.com/srcfl/ftw +maintainer: Sourceful Energy diff --git a/docs/ha-addon.md b/docs/ha-addon.md new file mode 100644 index 00000000..efc1de42 --- /dev/null +++ b/docs/ha-addon.md @@ -0,0 +1,160 @@ +# Home Assistant add-on + +This documents the first-party Home Assistant OS / Supervised add-on that +ships in [`deploy/homeassistant/`](../deploy/homeassistant/), why the +community add-on stopped tracking releases, and what is left to automate. +For MQTT-bridge integration (running FTW anywhere and surfacing it as HA +entities) see [`ha-integration.md`](ha-integration.md) — that is a separate +concern from running the app *as* an add-on. + +## Findings: the project was restructured under Sourceful + +The community add-on +([`erikarenhill/ha-addon-forty-two-watts`](https://github.com/erikarenhill/ha-addon-forty-two-watts)) +was a four-line wrapper: + +```dockerfile +FROM ghcr.io/frahlg/forty-two-watts:v1.3.0 +USER root +WORKDIR /data +CMD ["-config", "/data/config.yaml", "-web", "/app/web", "-drivers", "/app/drivers"] +``` + +with `version: "1.3.0"`. Checking it against the current upstream +(`srcfl/ftw` master + the published images) turned up: + +1. **The image was renamed.** The canonical image is now + **`ghcr.io/srcfl/ftw`**, plus **`ghcr.io/srcfl/ftw-updater`** and + **`ghcr.io/srcfl/ftw-optimizer`**. The old + `ghcr.io/frahlg/forty-two-watts` path is kept alive only as a + **byte-identical legacy mirror** — `srcfl/ftw:latest` and the mirror's + `latest` resolve to the *same manifest digest* (verified against GHCR). + That mirror is why the community add-on still *builds*. + +2. **The runtime binary was renamed** to **`/app/ftw`** + (`ENTRYPOINT ["/app/ftw"]`). This is *not* a break for the wrapper: it + overrides `CMD` with **flags only** and inherits `ENTRYPOINT`, so the name + is irrelevant. The `-config/-web/-drivers/-user-drivers` flag interface is + unchanged. + +3. **The MPC optimizer runs as a separate `ftw-optimizer` sidecar** (a + CVXPY/Python subsystem in `optimizer/`, `Dockerfile.optimizer`, its own + compose service). See the packaging-limitations section — this is the one + restructure a single-container add-on genuinely cannot mirror. + +4. **The image is `/app/data`-centric and non-root** (`WORKDIR /app/data`, + `VOLUME /app/data`, `USER 100:101`, a `HEALTHCHECK` on `/api/health`, a + `CMD` hardcoding `/app/data` + `-user-drivers /app/data/drivers`). The app + resolves relative state paths (`state.db`, `cold/`) against the CWD, so + that WORKDIR is load-bearing. A thin external wrapper has to override USER + + WORKDIR + the full CMD and drifts on every one of these changes — the + community CMD predates `-user-drivers`, so operator hot-reload drivers were + silently not loaded. + +### So — does the community add-on "work"? + +**It still runs, but it is stale and lossy.** Pinned to `:v1.3.0` it serves an +old build. If bumped to a current tag on the still-live mirror (or, better, +the canonical `srcfl/ftw`), the wrapper *does* start — the binary rename is +invisible and the flags are unchanged — but with two regressions from the +restructure: the missing `-user-drivers` overlay (fixed here), and **no +optimizer sidecar** (structural; see below). + +The root cause is a namespace rename shielded by a mirror plus feature drift a +hand-maintained external wrapper can't keep up with — not a hard runtime +break, and *not* a version-ordering problem: the current release line is +`1.4.0` (`package.json`, latest GitHub release, and the `docker-compose.yml` +image tag all agree), so `1.3.0 < 1.4.0` and Supervisor would happily offer an +update to a rebuilt add-on. (GHCR also carries unrelated edge/beta `0.x` tags +and a legacy-mirror `v2.x` line; ignore those and track the release line.) + +## The fix: a versioned, in-tree add-on + +`deploy/homeassistant/` is a complete HA add-on repository (a +`repository.yaml` plus the `ftw/` add-on) living beside the code, so each +release can bump the pinned image tag (`build.yaml`) and the add-on `version:` +(`config.yaml`) in the same commit. + +Design choices: + +- **Target the canonical `ghcr.io/srcfl/ftw`** (pinned `v1.4.0`), not the + legacy mirror. +- **Rebase wrapper, not a plain `image:`.** HA add-ons cannot override CMD on + a prebuilt `image:`; the wrapper repoints WORKDIR/USER/CMD at `/data` in one + place. Supervisor builds it locally on install; no add-on-side CI. +- **Restore `-user-drivers /data/drivers`** so hot-reload drivers persist + across add-on updates. +- **Host networking, no Ingress** (Modbus TCP / LAN MQTT / mDNS; app isn't + `X-Ingress-Path` aware). +- **In-app self-update stays off** — Supervisor owns the add-on lifecycle. + +### Verification performed + +- Confirmed the mirror identity (`srcfl/ftw:latest` == mirror `latest`, same + manifest digest) and that `ghcr.io/srcfl/ftw:v1.4.0` exists for the pinned + arches, directly against the GHCR registry. +- Confirmed the current ENTRYPOINT / CMD / USER / WORKDIR / HEALTHCHECK from + `master`'s `Dockerfile`, and that the flag interface the wrapper depends on + is unchanged. +- **Not** yet run: a live Supervisor build + boot. No Docker daemon is + available in the authoring environment, and the image blob CDN + (`pkg-containers.githubusercontent.com`) is blocked by the outbound proxy, + so a container smoke-test is a remaining manual step (below). + +## Storage & sizing + +Add-on **image size** is not a Supervisor-enforced limit and is not the +concern here: the core Go image is tens of MB. The only image-size risk is +bundling the Python `ftw-optimizer` into the same container (a HA add-on is +single-container) — Python + numeric deps add hundreds of MB. That argues for +graceful-degrade-without-sidecar rather than bundling. + +The real operational risk is **unbounded `/data` growth**. FTW is a +time-series system: the long-format TSDB rolls off to zstd Parquet cold +storage and, per `docs/tsdb.md`, *"a year of 50 metrics is typically a few +GB"*, plus `state.db`. In the add-on model that all lands in the add-on's +`/data` on the **shared HA OS data partition** (Core + every add-on + media + +backups). On an HA Green / Pi SD card that is a real squeeze, and it compounds +through **backups**: HA includes an add-on's `/data` in full backups, and +add-on data is the classic cause of runaway backup size (community reports of +add-ons inflating a backup to 10–15 GB and filling `/backup`). + +Mitigations to bake into the add-on: + +- Don't bundle the optimizer; degrade gracefully. +- Default `state.cold_dir` onto `/share` (or a mounted data disk) so history + isn't on the OS partition; expose `RecentRetention` for tuning. +- Document a minimum-storage recommendation (SSD / large eMMC, not a small SD + card) and advise excluding FTW data from routine backups. + +Refs: [HA — free up storage](https://www.home-assistant.io/more-info/free-space/), +[HA developer — OS partitioning](https://developers.home-assistant.io/docs/operating-system/partition/), +[community — full backup size very large](https://community.home-assistant.io/t/full-backup-size-very-large/713768). + +## Packaging limitations to resolve before release + +- [ ] **Optimizer sidecar.** Upstream runs the MPC optimizer as a separate + `ftw-optimizer` container. A HA add-on is single-container. Decide: + (a) confirm the app degrades gracefully with no sidecar (built-in / Go + fallback), or (b) bundle the optimizer process in the same image via an + s6 / supervisor init. Biggest open item. +- [ ] **Storage defaults.** Point `state.cold_dir` off the OS partition (e.g. + `/share`), expose retention, and document a min-storage recommendation — + see Storage & sizing above. +- [ ] **Distribution.** A HA add-on repository must have `repository.yaml` at + its git root, so Supervisor can't add the monorepo root URL directly. + Mirror `deploy/homeassistant/` to a dedicated repo (e.g. `srcfl/ha-addons`) + from CI on release; until then, copy it onto the HA host under `/addons` + (Supervised) or `/root/addons` for local testing. +- [ ] **Release automation.** CI step to bump `build.yaml` + `config.yaml` + versions and mirror the subtree on each release. +- [ ] **Options schema.** Map HA `options.json` onto the app's YAML config for + common knobs (site name, currency, MQTT) so basic setup doesn't require + the web wizard. +- [ ] **Icon/logo** (`icon.png`, `logo.png`). +- [ ] **Smoke test.** `docker build --build-arg + BUILD_FROM=ghcr.io/srcfl/ftw:v1.4.0 deploy/homeassistant/ftw`, install + via Supervisor, run the setup wizard, confirm `/data` persistence and the + `/data/drivers` overlay. +- [ ] Decide whether to archive/redirect the community + `erikarenhill/ha-addon-forty-two-watts` repo. From f706f5c370d1a52d53c41e8d971257686734300e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 12:00:00 +0000 Subject: [PATCH 2/3] feat(ha): all-in-one add-on image (bundle core + optimizer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HA add-ons are single-container, but FTW now runs as several (core, Python/CVXPY optimizer, updater, mosquitto). Bundle the two that matter — core + optimizer — into one image so the add-on ships the full CVXPY optimizer with no extra setup. Enabler: core <-> optimizer already talk over a Unix socket (FTW_OPTIMIZER_TRANSPORT=auto, optimizer runs network_mode:none), so in one container they just share /run/ftw-optimizer/optimizer.sock. - Dockerfile bases on the optimizer image (Debian python:3.12-slim + CVXPY venv) and COPY --from the fully static, CGO-free Go core; runs unchanged. - run.sh supervises both under tini (PID1): start optimizer, start core; if either exits, tear down and exit non-zero so Supervisor restarts the whole add-on. Health check hits core's /api/health. - build.yaml pins both images (they version independently: core v1.4.0, optimizer v1.3.1) and passes FTW_CORE_FROM. - Drop the updater (Supervisor owns updates) and mosquitto (HA add-on). - Trade-off documented: the CVXPY stack makes the image a few hundred MB; a slim optimizer-less variant is a trivial derivative. docs/ha-addon.md gains a "multiple containers -> one image" section, the optimizer open-item is resolved, and the smoke-test command is updated. Co-Authored-By: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com> Co-Authored-By: Erik Arenhill <758402+erikarenhill@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01HBjLafzpEYX1dptEE7AGsx --- .changeset/ha-addon-first-party.md | 31 ++++++------ deploy/homeassistant/README.md | 9 +++- deploy/homeassistant/ftw/DOCS.md | 16 +++--- deploy/homeassistant/ftw/Dockerfile | 74 ++++++++++++++++++--------- deploy/homeassistant/ftw/build.yaml | 28 +++++----- deploy/homeassistant/ftw/config.yaml | 11 ++-- deploy/homeassistant/ftw/run.sh | 28 ++++++++++ docs/ha-addon.md | 76 +++++++++++++++++++++++----- 8 files changed, 194 insertions(+), 79 deletions(-) create mode 100755 deploy/homeassistant/ftw/run.sh diff --git a/.changeset/ha-addon-first-party.md b/.changeset/ha-addon-first-party.md index c564b35f..f52fb012 100644 --- a/.changeset/ha-addon-first-party.md +++ b/.changeset/ha-addon-first-party.md @@ -2,21 +2,22 @@ "ftw": minor --- -Add a first-party Home Assistant OS / Supervised add-on under +Add a first-party, all-in-one Home Assistant OS / Supervised add-on under `deploy/homeassistant/`, versioned in lockstep with releases, replacing the -stale community add-on (`erikarenhill/ha-addon-forty-two-watts`) that was -pinned to `ghcr.io/frahlg/forty-two-watts:v1.3.0`. +stale community add-on (`erikarenhill/ha-addon-forty-two-watts`). -The project was restructured under Sourceful: the canonical image is now -`ghcr.io/srcfl/ftw` (the old path survives only as a byte-identical mirror), -the runtime binary is `/app/ftw`, and the MPC optimizer runs as a separate -`ftw-optimizer` sidecar. A hand-maintained external wrapper can't follow that -drift. +FTW now ships as several containers (core, Python/CVXPY optimizer, updater, +Mosquitto). A HA add-on is single-container, so the add-on **bundles core + +optimizer into one image**: since the two already talk over a Unix socket +(`FTW_OPTIMIZER_TRANSPORT=auto`), the Dockerfile bases on the optimizer image +and copies in the fully static Go core, with a `tini`-supervised `run.sh` +running both. This gives the full CVXPY optimizer with no extra setup — a real +"install one add-on and it works" trial. The updater is dropped (Supervisor +owns updates) and Mosquitto is left to the HA Mosquitto add-on. -The in-tree add-on targets the canonical `srcfl/ftw` (pinned `v1.4.0`), -rebases it onto Supervisor's `/data` volume, restores the `-user-drivers` -overlay so hot-reload Lua drivers persist across updates, and uses host -networking (Modbus TCP / LAN MQTT / mDNS). README gains an install section; -the full analysis, storage-growth guidance, and remaining packaging -limitations (optimizer sidecar in a single-container add-on, `/data` sizing, -distribution/mirror automation) are in `docs/ha-addon.md`. +Targets canonical `ghcr.io/srcfl/ftw:v1.4.0` + `ghcr.io/srcfl/ftw-optimizer:v1.3.1` +(they version independently), rebases onto Supervisor's `/data`, restores the +`-user-drivers` overlay, and uses host networking (Modbus TCP / LAN MQTT / +mDNS). The full analysis, storage-growth guidance, and remaining items +(CI-validated tag pairing, optional slim variant, options schema) are in +`docs/ha-addon.md`. diff --git a/deploy/homeassistant/README.md b/deploy/homeassistant/README.md index 455636fe..7ee9f239 100644 --- a/deploy/homeassistant/README.md +++ b/deploy/homeassistant/README.md @@ -10,12 +10,17 @@ deploy/homeassistant/ ├── repository.yaml # add-on repository descriptor └── ftw/ ├── config.yaml # add-on manifest (arch, ports, host_network, map) - ├── build.yaml # per-arch base image (pinned upstream tag) - ├── Dockerfile # rebases upstream image onto Supervisor's /data + ├── build.yaml # pinned base images (optimizer + core), per arch + ├── Dockerfile # all-in-one: core + optimizer in one container + ├── run.sh # tini-supervised two-process entrypoint ├── DOCS.md # user-facing docs shown in the HA UI └── README.md # this add-on's short description ``` +This is an **all-in-one** image: HA add-ons are single-container, so the Go +core and the Python/CVXPY optimizer (normally separate containers) are bundled +and supervised together. See [`docs/ha-addon.md`](../../docs/ha-addon.md). + ## Why it lives here The previous community add-on diff --git a/deploy/homeassistant/ftw/DOCS.md b/deploy/homeassistant/ftw/DOCS.md index f273a492..6320ec5f 100644 --- a/deploy/homeassistant/ftw/DOCS.md +++ b/deploy/homeassistant/ftw/DOCS.md @@ -34,13 +34,17 @@ Because of host networking, the UI is served directly on **port 8080** of the Home Assistant host (not via Ingress). If something else already uses port 8080 on that host, the add-on will fail to bind. -### Optimizer +### Optimizer (included) -Upstream runs the long-horizon (CVXPY) optimizer as a **separate -`ftw-optimizer` container**. A Home Assistant add-on is single-container, so -that sidecar is not bundled here and the app runs with its built-in / Go -fallback planning. Core safety and dispatch are unaffected; advanced -long-horizon optimization may be reduced. +Upstream runs FTW as several containers — the Go core, the Python/CVXPY +optimizer, an updater sidecar and (optionally) Mosquitto. A Home Assistant +add-on is single-container, so this is an **all-in-one image**: it bundles the +**core and the optimizer together** and supervises both, so you get the full +CVXPY optimizer with no extra setup. (The updater is dropped — Supervisor +handles updates — and Mosquitto is left to the HA Mosquitto add-on.) + +Because the CVXPY stack (numpy/scipy) is bundled, the image is a few hundred MB +— a one-time download. If either process stops the add-on restarts as a whole. ### Adding your own Lua drivers diff --git a/deploy/homeassistant/ftw/Dockerfile b/deploy/homeassistant/ftw/Dockerfile index 410977af..52145d20 100644 --- a/deploy/homeassistant/ftw/Dockerfile +++ b/deploy/homeassistant/ftw/Dockerfile @@ -1,31 +1,57 @@ -# Wraps the upstream multi-arch FTW image so it runs against Home Assistant -# Supervisor's /data volume instead of the image-native /app/data. Supervisor -# builds this locally on install using build.yaml's per-arch `build_from` -# (injected as BUILD_FROM); no add-on-side CI needed. +# All-in-one Home Assistant add-on image for FTW. # -# Why a wrapper and not a plain `image:` reference in config.yaml: -# the upstream image bakes in WORKDIR /app/data, VOLUME /app/data, a non-root -# USER (100:101), a HEALTHCHECK, and a CMD that hardcodes /app/data paths. HA -# add-ons persist /data (owned by root) and cannot override CMD on a prebuilt -# `image:`. Rebasing here lets us repoint all of that at /data in one place. +# FTW normally runs as several containers (docker-compose.yml): the Go core, +# the Python/CVXPY optimizer, an updater sidecar and (optionally) Mosquitto. +# A Home Assistant add-on is single-container, so this image bundles the two +# that matter for the app itself — CORE + OPTIMIZER — into one. They already +# communicate over a Unix socket (/run/ftw-optimizer/optimizer.sock), so +# inside one container they simply share that path; there is no networking +# between them and nothing to coordinate. # -# The upstream ENTRYPOINT is /app/ftw. Because we override CMD with FLAGS ONLY -# (not the binary path) and inherit ENTRYPOINT, the wrapper is unaffected by -# the binary name — the -config/-web/-drivers/-user-drivers flag interface is -# what matters and is unchanged. -ARG BUILD_FROM +# Dropped on purpose: +# - ftw-updater — Supervisor owns add-on updates. +# - mosquitto — use the HA Mosquitto add-on, or any LAN broker. +# +# Base = the optimizer image (Debian `python:3.12-slim` + the CVXPY venv at +# /opt/venv). The core binary is a fully static, CGO-free Go build, so it runs +# on this Debian base unchanged. build.yaml pins BOTH images per release; they +# version independently (e.g. core v1.4.0 + optimizer v1.3.1). + +ARG BUILD_FROM # ghcr.io/srcfl/ftw-optimizer: +ARG FTW_CORE_FROM=ghcr.io/srcfl/ftw:v1.4.0 + +FROM ${FTW_CORE_FROM} AS core + # hadolint ignore=DL3006 FROM ${BUILD_FROM} - -# Supervisor owns /data as root; run as root so the app can create -# config.yaml + state.db there. (The upstream image uses uid 100 for the -# standalone docker-compose deploy, which is not how add-ons persist.) USER root + +# Core needs TLS roots (price/weather HTTPS) and tzdata (tz-aware windows). +# tini is PID 1 so the two long-running children get reaped cleanly. +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata tini \ + && rm -rf /var/lib/apt/lists/* + +# Core artifacts from the Go image: the static binary + bundled web + drivers. +COPY --from=core /app/ftw /app/ftw +COPY --from=core /app/web /app/web +COPY --from=core /app/drivers /app/drivers + +# Supervisor's persistent volume + the shared optimizer socket directory. +RUN mkdir -p /data/drivers /run/ftw-optimizer WORKDIR /data -# The app resolves relative state paths (state.db, cold/) against the CWD, so -# WORKDIR /data is load-bearing — it puts persistent state on the add-on -# volume. -user-drivers /data/drivers keeps the /app/drivers image bundle -# immutable while letting operators drop hot-reloadable Lua under -# /data/drivers, which survives add-on updates. Mirrors the upstream CMD. -CMD ["-config", "/data/config.yaml", "-web", "/app/web", "-drivers", "/app/drivers", "-user-drivers", "/data/drivers"] +# transport=auto: core dials the bundled optimizer over the socket below, and +# falls back to the in-Go DP planner if it is ever unavailable. +ENV FTW_OPTIMIZER_TRANSPORT=auto \ + FTW_OPTIMIZER_SOCKET=/run/ftw-optimizer/optimizer.sock + +COPY run.sh /run.sh +RUN chmod +x /run.sh + +# Health = the user-facing service (core API on :8080), not the optimizer. +HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=6 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/health', timeout=4)" || exit 1 + +ENTRYPOINT ["/usr/bin/tini", "--", "/run.sh"] diff --git a/deploy/homeassistant/ftw/build.yaml b/deploy/homeassistant/ftw/build.yaml index 8fa726a0..dc5c018f 100644 --- a/deploy/homeassistant/ftw/build.yaml +++ b/deploy/homeassistant/ftw/build.yaml @@ -1,19 +1,21 @@ -# Per-arch base image for the local Supervisor build. +# Base images for the all-in-one add-on build (see Dockerfile). # -# Pin the canonical Sourceful image `ghcr.io/srcfl/ftw`. It is a single -# multi-arch manifest (linux/amd64 + linux/arm64), so the SAME tag serves -# both HA architectures — Docker selects the layer matching the build -# platform. +# build_from = the OPTIMIZER image (Debian python:3.12-slim + the CVXPY venv); +# the static Go core is copied in from FTW_CORE_FROM. Both are multi-arch +# manifests, so the same tags serve amd64 + aarch64. # -# Pin a concrete release tag (never :latest) so the build is reproducible and -# Supervisor's `version:` maps to exactly one image. Bump this together with -# config.yaml `version:` on every release. GHCR also carries unrelated -# edge/beta and legacy-mirror tags — ignore those; track the release line -# (package.json), currently v1.4.0. +# The two images VERSION INDEPENDENTLY — core follows the app release line +# (v1.4.0) while the optimizer has its own (v1.3.1 stable). Pin both to a +# validated pair (the tags the docker-compose `latest` stack resolves to) and +# bump them together with config.yaml `version:` each release. Never :latest — +# the build must be reproducible. build_from: - amd64: ghcr.io/srcfl/ftw:v1.4.0 - aarch64: ghcr.io/srcfl/ftw:v1.4.0 + amd64: ghcr.io/srcfl/ftw-optimizer:v1.3.1 + aarch64: ghcr.io/srcfl/ftw-optimizer:v1.3.1 + +args: + FTW_CORE_FROM: ghcr.io/srcfl/ftw:v1.4.0 labels: - org.opencontainers.image.title: "FTW (Home Assistant add-on)" + org.opencontainers.image.title: "FTW (Home Assistant add-on, all-in-one)" org.opencontainers.image.source: "https://github.com/srcfl/ftw" diff --git a/deploy/homeassistant/ftw/config.yaml b/deploy/homeassistant/ftw/config.yaml index 0855312b..38c64e60 100644 --- a/deploy/homeassistant/ftw/config.yaml +++ b/deploy/homeassistant/ftw/config.yaml @@ -49,10 +49,11 @@ map: # No options schema yet — wiring HA options.json through to the app's YAML # config is tracked as follow-up in docs/ha-addon.md. # -# KNOWN LIMITATION (see docs/ha-addon.md): upstream runs the MPC optimizer as -# a separate `ftw-optimizer` sidecar container. A HA add-on is single- -# container, so that sidecar is not present here; the app runs with its -# in-process / auto optimizer transport. Full optimizer parity needs either -# graceful degradation in the app or bundling the optimizer via s6. +# This is an ALL-IN-ONE image: upstream runs FTW as several containers (core, +# Python/CVXPY optimizer, updater, Mosquitto), but a HA add-on is single- +# container, so the Dockerfile bundles core + optimizer into one and supervises +# both. The optimizer is included — no separate sidecar needed. See +# docs/ha-addon.md. (Trade-off: the CVXPY stack makes the image a few hundred +# MB; on very small SD-card hosts a slim optimizer-less build is possible.) options: {} schema: {} diff --git a/deploy/homeassistant/ftw/run.sh b/deploy/homeassistant/ftw/run.sh new file mode 100755 index 00000000..995a16f9 --- /dev/null +++ b/deploy/homeassistant/ftw/run.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# All-in-one supervisor for the FTW Home Assistant add-on. +# +# Runs the two bundled processes — the Python/CVXPY optimizer and the Go core — +# in one container. They share the Unix socket at /run/ftw-optimizer. If either +# process exits, we tear the other down and exit non-zero so Home Assistant +# Supervisor restarts the whole add-on cleanly (rather than limping on with +# half the stack). tini (PID 1) reaps everything. +set -uo pipefail + +mkdir -p /run/ftw-optimizer /data/drivers + +# 1) Python/CVXPY optimizer daemon — creates the socket that core dials. +/opt/venv/bin/ftw-optimizer & +OPT_PID=$! + +# 2) Go core — transport=auto uses the socket above, else the in-Go DP +# planner. Serves the web UI / HTTP API on :8080. +/app/ftw -config /data/config.yaml -web /app/web -drivers /app/drivers -user-drivers /data/drivers & +CORE_PID=$! + +shutdown() { kill "$OPT_PID" "$CORE_PID" 2>/dev/null || true; } +trap shutdown INT TERM + +# Return as soon as EITHER child exits. +wait -n "$OPT_PID" "$CORE_PID" +shutdown +wait 2>/dev/null || true diff --git a/docs/ha-addon.md b/docs/ha-addon.md index efc1de42..d1804ec7 100644 --- a/docs/ha-addon.md +++ b/docs/ha-addon.md @@ -79,9 +79,11 @@ Design choices: - **Target the canonical `ghcr.io/srcfl/ftw`** (pinned `v1.4.0`), not the legacy mirror. -- **Rebase wrapper, not a plain `image:`.** HA add-ons cannot override CMD on - a prebuilt `image:`; the wrapper repoints WORKDIR/USER/CMD at `/data` in one - place. Supervisor builds it locally on install; no add-on-side CI. +- **All-in-one image (core + optimizer).** Rather than a thin single-image + wrapper that ships without the CVXPY optimizer, the add-on bundles core and + the optimizer into one container — see the next section. HA add-ons also + cannot override CMD on a prebuilt `image:`, so a local build is required + anyway; Supervisor builds it on install, no add-on-side CI. - **Restore `-user-drivers /data/drivers`** so hot-reload drivers persist across add-on updates. - **Host networking, no Ingress** (Modbus TCP / LAN MQTT / mDNS; app isn't @@ -91,8 +93,9 @@ Design choices: ### Verification performed - Confirmed the mirror identity (`srcfl/ftw:latest` == mirror `latest`, same - manifest digest) and that `ghcr.io/srcfl/ftw:v1.4.0` exists for the pinned - arches, directly against the GHCR registry. + manifest digest) and that `ghcr.io/srcfl/ftw:v1.4.0` and + `ghcr.io/srcfl/ftw-optimizer:v1.3.1` both exist as multi-arch + (amd64 + aarch64) manifests, directly against the GHCR registry. - Confirmed the current ENTRYPOINT / CMD / USER / WORKDIR / HEALTHCHECK from `master`'s `Dockerfile`, and that the flag interface the wrapper depends on is unchanged. @@ -101,6 +104,48 @@ Design choices: (`pkg-containers.githubusercontent.com`) is blocked by the outbound proxy, so a container smoke-test is a remaining manual step (below). +## Multiple containers → one image (all-in-one) + +Upstream now ships FTW as several containers (`docker-compose.yml`): + +| Service | Image | Role | In the add-on? | +|---|---|---|---| +| `ftw` | `ghcr.io/srcfl/ftw` | Go core: state, safety, dispatch, API, UI | **yes** | +| `ftw-optimizer` | `ghcr.io/srcfl/ftw-optimizer` | Python/CVXPY long-horizon optimizer | **yes — bundled** | +| `ftw-updater` | `ghcr.io/srcfl/ftw-updater` | Docker-socket self-update sidecar | no — Supervisor owns updates | +| `mosquitto` | `eclipse-mosquitto:2` | optional embedded MQTT broker | no — use the HA Mosquitto add-on | + +A Home Assistant add-on is **single-container**, so the two that matter for the +app — core and optimizer — are combined into one image. The key enabler is +that **core ↔ optimizer already talk over a Unix socket** +(`/run/ftw-optimizer/optimizer.sock`, `FTW_OPTIMIZER_TRANSPORT=auto`, +`ftw-optimizer` runs `network_mode: none`). Inside one container the two +processes simply share that socket path — no networking, no orchestration. + +Build (`deploy/homeassistant/ftw/Dockerfile`): + +- **Base = the optimizer image** (Debian `python:3.12-slim` + the CVXPY venv at + `/opt/venv`). The core binary is a **fully static, CGO-free** Go build, so it + is `COPY --from`'d onto the Debian base and runs unchanged. +- **`run.sh` supervises both** under `tini` (PID 1): it starts the optimizer + daemon, then core (`transport=auto` → the socket); if either exits it tears + the other down and exits non-zero so Supervisor restarts the add-on as a + whole. (`s6-overlay` would be the heavier, more granular alternative — a + simple two-process supervisor is enough here.) +- **Version pairing.** Core and the optimizer **version independently** (core + `v1.4.0`; the optimizer's own latest stable is `v1.3.1`). `build.yaml` pins + both — the tags the compose `latest` stack resolves to — and they move + together each release. Because `transport=auto` falls back to the in-Go DP + planner, a mismatched pair degrades safely rather than breaking. +- **Trade-off: size.** Bundling numpy/scipy/CVXPY makes the image a few hundred + MB (vs tens of MB for core alone) — a one-time pull. On very small SD-card + hosts a slim, optimizer-less build (drop the `core` copy target and base on + `srcfl/ftw` instead) is a trivial derivative; core then falls back to the Go + DP planner. + +This is what makes it a genuine "install one add-on and it all works" trial — +the point of packaging it for Home Assistant. + ## Storage & sizing Add-on **image size** is not a Supervisor-enforced limit and is not the @@ -133,11 +178,10 @@ Refs: [HA — free up storage](https://www.home-assistant.io/more-info/free-spac ## Packaging limitations to resolve before release -- [ ] **Optimizer sidecar.** Upstream runs the MPC optimizer as a separate - `ftw-optimizer` container. A HA add-on is single-container. Decide: - (a) confirm the app degrades gracefully with no sidecar (built-in / Go - fallback), or (b) bundle the optimizer process in the same image via an - s6 / supervisor init. Biggest open item. +- [x] **Optimizer sidecar** → resolved by the all-in-one image (core + + optimizer bundled, supervised by `tini`). Remaining sub-decisions: pin a + CI-validated core/optimizer tag pair, and decide whether to also offer a + slim optimizer-less variant for tiny hosts. - [ ] **Storage defaults.** Point `state.cold_dir` off the OS partition (e.g. `/share`), expose retention, and document a min-storage recommendation — see Storage & sizing above. @@ -152,9 +196,13 @@ Refs: [HA — free up storage](https://www.home-assistant.io/more-info/free-spac common knobs (site name, currency, MQTT) so basic setup doesn't require the web wizard. - [ ] **Icon/logo** (`icon.png`, `logo.png`). -- [ ] **Smoke test.** `docker build --build-arg - BUILD_FROM=ghcr.io/srcfl/ftw:v1.4.0 deploy/homeassistant/ftw`, install - via Supervisor, run the setup wizard, confirm `/data` persistence and the - `/data/drivers` overlay. +- [ ] **Smoke test** (needs Docker; not run in the authoring env). Build the + all-in-one: + `docker build --build-arg BUILD_FROM=ghcr.io/srcfl/ftw-optimizer:v1.3.1 + --build-arg FTW_CORE_FROM=ghcr.io/srcfl/ftw:v1.4.0 + deploy/homeassistant/ftw`. Then install via Supervisor, run the setup + wizard, confirm the optimizer socket comes up (core logs + `transport=auto` connected, not the DP fallback), `/data` persistence, + and the `/data/drivers` overlay. - [ ] Decide whether to archive/redirect the community `erikarenhill/ha-addon-forty-two-watts` repo. From 54d0ef51a1f1fb55132dfdee8425189af0370bd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:55:13 +0000 Subject: [PATCH 3/3] fix(ha): make add-on supervisor fail-correct and degrade gracefully The previous run.sh had two defects flagged in review: 1. It always exited 0 (the final `wait ... || true` swallowed the code), so a crashed core never signalled failure and Supervisor had no reason to restart the add-on. 2. `wait -n` on both PIDs plus an unconditional teardown coupled the optimizer's liveness to core: a transient optimizer crash (e.g. CVXPY OOM on a small Pi) tore down a healthy core, turning graceful degradation into a whole-add-on crash loop. Rework the supervision model around core as the primary process: - Wait on CORE only and `exit` with its code, so success/failure propagates to Supervisor (non-zero -> restart, SIGTERM -> clean 0). - Supervise the optimizer in a background loop with capped exponential backoff and a healthy-run reset; after repeated failures it gives up and leaves core running on the in-Go DP planner (transport=auto) instead of restarting the whole container. Its death no longer touches core. Control flow verified with stubbed binaries: optimizer crash-loop leaves core running, core rc=7 propagates, SIGTERM exits 0. The socket path and optimizer entrypoint still need the live Supervisor smoke test. Co-Authored-By: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com> Co-Authored-By: Erik Arenhill <758402+erikarenhill@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HBjLafzpEYX1dptEE7AGsx --- deploy/homeassistant/ftw/run.sh | 80 ++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/deploy/homeassistant/ftw/run.sh b/deploy/homeassistant/ftw/run.sh index 995a16f9..1a963e0a 100755 --- a/deploy/homeassistant/ftw/run.sh +++ b/deploy/homeassistant/ftw/run.sh @@ -1,28 +1,76 @@ #!/usr/bin/env bash # All-in-one supervisor for the FTW Home Assistant add-on. # -# Runs the two bundled processes — the Python/CVXPY optimizer and the Go core — -# in one container. They share the Unix socket at /run/ftw-optimizer. If either -# process exits, we tear the other down and exit non-zero so Home Assistant -# Supervisor restarts the whole add-on cleanly (rather than limping on with -# half the stack). tini (PID 1) reaps everything. +# Two bundled processes share one container: +# - CORE (primary): the Go app — state, safety, dispatch, web UI/API on :8080. +# - OPTIMIZER (optional): the Python/CVXPY planner, reached by core over the +# Unix socket in /run/ftw-optimizer. +# +# Supervision model (why this is not a symmetric "wait on either"): +# - CORE is the add-on. When it exits we exit with ITS code, so Home +# Assistant Supervisor sees success vs. failure correctly and restarts the +# add-on on a crash. +# - The OPTIMIZER is optional: core runs FTW_OPTIMIZER_TRANSPORT=auto and +# degrades (bundled Python worker -> in-Go DP planner) when the socket is +# down. So the optimizer dying must NOT take core down. We restart it with +# capped backoff to recover from a transient crash; after repeated failures +# we give up and leave core running degraded, rather than crash-looping the +# whole add-on. +# - tini (PID 1) reaps zombies; SIGTERM/SIGINT from Supervisor tears both +# down and exits 0 (a clean stop, not a failure). set -uo pipefail mkdir -p /run/ftw-optimizer /data/drivers -# 1) Python/CVXPY optimizer daemon — creates the socket that core dials. -/opt/venv/bin/ftw-optimizer & -OPT_PID=$! +# --- optional optimizer, kept alive in the background -------------------- +supervise_optimizer() { + local child="" delay=1 tries=0 start rc + # Own trap: when the parent stops us, take the current optimizer child with + # us. child stays "" until the first spawn, so we never kill process group 0. + trap 'if [ -n "$child" ]; then kill "$child" 2>/dev/null; fi; exit 0' TERM INT + while :; do + start=$SECONDS + /opt/venv/bin/ftw-optimizer & + child=$! + wait "$child" + rc=$? + # Ran healthily for a while -> reset the crash-loop backoff. + if [ $((SECONDS - start)) -ge 60 ]; then + tries=0 + delay=1 + fi + tries=$((tries + 1)) + if [ "$tries" -ge 10 ]; then + echo "run.sh: optimizer keeps exiting (last rc=$rc); leaving core on the DP planner" >&2 + return 0 + fi + echo "run.sh: optimizer exited (rc=$rc); restart ${tries}/10 in ${delay}s" >&2 + sleep "$delay" + [ "$delay" -lt 30 ] && delay=$((delay * 2)) + done +} +supervise_optimizer & +OPT_SUP=$! -# 2) Go core — transport=auto uses the socket above, else the in-Go DP -# planner. Serves the web UI / HTTP API on :8080. +# --- core: the add-on's primary process ---------------------------------- /app/ftw -config /data/config.yaml -web /app/web -drivers /app/drivers -user-drivers /data/drivers & CORE_PID=$! -shutdown() { kill "$OPT_PID" "$CORE_PID" 2>/dev/null || true; } -trap shutdown INT TERM +# Supervisor stop -> tear both down and exit cleanly (0, not a failure). +shutdown() { + trap - TERM INT + kill "$CORE_PID" 2>/dev/null || true + kill "$OPT_SUP" 2>/dev/null || true # its own trap stops the optimizer + exit 0 +} +trap shutdown TERM INT + +# Block on CORE only. Optimizer restarts happen inside its supervisor and never +# reach here, so core is free to degrade instead of being torn down with it. +wait "$CORE_PID" +rc=$? -# Return as soon as EITHER child exits. -wait -n "$OPT_PID" "$CORE_PID" -shutdown -wait 2>/dev/null || true +# Core is gone -> the add-on is done. Stop the optimizer supervisor and +# propagate core's exit code so Supervisor restarts the add-on on failure. +kill "$OPT_SUP" 2>/dev/null || true +exit "$rc"