diff --git a/.changeset/ha-addon-first-party.md b/.changeset/ha-addon-first-party.md new file mode 100644 index 00000000..f52fb012 --- /dev/null +++ b/.changeset/ha-addon-first-party.md @@ -0,0 +1,23 @@ +--- +"ftw": minor +--- + +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`). + +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. + +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/.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..7ee9f239 --- /dev/null +++ b/deploy/homeassistant/README.md @@ -0,0 +1,50 @@ +# 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 # 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 +([`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..6320ec5f --- /dev/null +++ b/deploy/homeassistant/ftw/DOCS.md @@ -0,0 +1,81 @@ +# 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 (included) + +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 + +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..52145d20 --- /dev/null +++ b/deploy/homeassistant/ftw/Dockerfile @@ -0,0 +1,57 @@ +# All-in-one Home Assistant add-on image for FTW. +# +# 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. +# +# 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} +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 + +# 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/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..dc5c018f --- /dev/null +++ b/deploy/homeassistant/ftw/build.yaml @@ -0,0 +1,21 @@ +# Base images for the all-in-one add-on build (see Dockerfile). +# +# 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. +# +# 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-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, 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 new file mode 100644 index 00000000..38c64e60 --- /dev/null +++ b/deploy/homeassistant/ftw/config.yaml @@ -0,0 +1,59 @@ +# 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. +# +# 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..1a963e0a --- /dev/null +++ b/deploy/homeassistant/ftw/run.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# All-in-one supervisor for the FTW Home Assistant add-on. +# +# 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 + +# --- 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=$! + +# --- 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=$! + +# 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=$? + +# 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" 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..d1804ec7 --- /dev/null +++ b/docs/ha-addon.md @@ -0,0 +1,208 @@ +# 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. +- **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 + `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` 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. +- **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). + +## 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 +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 + +- [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. +- [ ] **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** (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.