From 25cba135ca1d502fe5c5bd98e106badc4426c749 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 28 Jul 2026 16:33:03 +0200 Subject: [PATCH] feat: show one bundled FTW version when packaged as the Home Assistant add-on When FTW_BUNDLE=home_assistant_addon is set by the bundle image, /api/components reports the packaging and Settings -> System shows the single bundled FTW version instead of the per-container Core/Optimizer breakdown with update and rollback buttons that Supervisor-owned installs cannot use. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ha-addon-bundle-version.md | 5 +++ go/cmd/ftw/main.go | 2 + go/internal/api/api.go | 7 ++++ go/internal/api/api_components.go | 3 ++ go/internal/api/api_components_test.go | 46 +++++++++++++++++++++ go/internal/components/bundle.go | 28 +++++++++++++ go/internal/components/bundle_test.go | 20 ++++++++++ web/settings/tabs/system.js | 55 +++++++++++++++++++++----- web/settings/tabs/system.test.mjs | 28 ++++++++++++- 9 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 .changeset/ha-addon-bundle-version.md create mode 100644 go/internal/components/bundle.go create mode 100644 go/internal/components/bundle_test.go diff --git a/.changeset/ha-addon-bundle-version.md b/.changeset/ha-addon-bundle-version.md new file mode 100644 index 00000000..5eb16818 --- /dev/null +++ b/.changeset/ha-addon-bundle-version.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show the single bundled FTW version in Settings → System when running as the Home Assistant add-on (`FTW_BUNDLE=home_assistant_addon`), instead of the per-container Core/Optimizer breakdown and update buttons that Supervisor-managed installs cannot use. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 509f7946..a0da35f0 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -30,6 +30,7 @@ import ( "github.com/srcfl/ftw/go/internal/battery" "github.com/srcfl/ftw/go/internal/caldavserver" "github.com/srcfl/ftw/go/internal/calendar" + "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/configreload" "github.com/srcfl/ftw/go/internal/control" @@ -2120,6 +2121,7 @@ func main() { }) return nil }, + Bundle: components.BundleFromEnv(), Version: Version, } srv := api.New(deps) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index fa5ed888..8a66e5a6 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -26,6 +26,7 @@ import ( "github.com/srcfl/ftw/go/internal/battery" "github.com/srcfl/ftw/go/internal/calendar" + "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/driverrepo" @@ -174,6 +175,12 @@ type Deps struct { // nil disables /api/restart (returns 503). Restart func(ctx context.Context) error + // Bundle describes single-image packaging (e.g. the Home Assistant + // add-on) whose host platform owns update and rollback. Nil means a + // native install; /api/components then reports each component + // separately. + Bundle *components.Bundle + Version string } diff --git a/go/internal/api/api_components.go b/go/internal/api/api_components.go index a58462c1..557686e2 100644 --- a/go/internal/api/api_components.go +++ b/go/internal/api/api_components.go @@ -66,6 +66,9 @@ func (s *Server) handleComponents(w http.ResponseWriter, r *http.Request) { if s.deps.DriverRepository != nil { result["drivers"] = s.deps.DriverRepository.Status() } + if s.deps.Bundle != nil { + result["bundle"] = s.deps.Bundle + } if s.deps.SelfUpdate != nil { result["updates"] = map[string]any{ "release": s.deps.SelfUpdate.Info(), diff --git a/go/internal/api/api_components_test.go b/go/internal/api/api_components_test.go index 42b9a966..f6dc546a 100644 --- a/go/internal/api/api_components_test.go +++ b/go/internal/api/api_components_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "testing" + "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/mpc" ) @@ -65,3 +66,48 @@ func TestComponentsReportsWorkerHealthFailure(t *testing.T) { t.Fatalf("optimizer status = %+v", body.Optimizer) } } + +func TestComponentsReportsBundlePackaging(t *testing.T) { + srv := New(&Deps{ + Version: "v1.10.0-beta.1", + Bundle: &components.Bundle{Kind: "home_assistant_addon", Version: "0.1.0-beta.1"}, + }) + req := httptest.NewRequest(http.MethodGet, "/api/components", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rr.Code, rr.Body.String()) + } + var body struct { + Core struct { + Version string `json:"version"` + } `json:"core"` + Bundle *components.Bundle `json:"bundle"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Bundle == nil || body.Bundle.Kind != "home_assistant_addon" || body.Bundle.Version != "0.1.0-beta.1" { + t.Fatalf("bundle = %+v, want home_assistant_addon 0.1.0-beta.1", body.Bundle) + } + if body.Core.Version != "v1.10.0-beta.1" { + t.Fatalf("core version = %q, want the bundled FTW version", body.Core.Version) + } +} + +func TestComponentsOmitsBundleForNativeInstalls(t *testing.T) { + srv := New(&Deps{Version: "v1.10.0-beta.1"}) + req := httptest.NewRequest(http.MethodGet, "/api/components", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rr.Code, rr.Body.String()) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if _, ok := body["bundle"]; ok { + t.Fatalf("bundle key present for native install: %s", rr.Body.String()) + } +} diff --git a/go/internal/components/bundle.go b/go/internal/components/bundle.go new file mode 100644 index 00000000..b317a450 --- /dev/null +++ b/go/internal/components/bundle.go @@ -0,0 +1,28 @@ +package components + +import "os" + +// Bundle identifies a distribution that ships Core and its companion +// components in a single image whose host platform owns install, update and +// rollback (e.g. the Home Assistant add-on, where Supervisor replaces the +// whole bundle at once). In that packaging the per-component version +// breakdown is meaningless to the operator: everything moves together under +// one bundled FTW release. +type Bundle struct { + // Kind names the packaging, e.g. "home_assistant_addon". + Kind string `json:"kind"` + // Version is the bundle's own release version (the add-on version), + // distinct from the FTW Core version compiled into the binary. + Version string `json:"version,omitempty"` +} + +// BundleFromEnv reads FTW_BUNDLE and FTW_BUNDLE_VERSION, set by bundle +// packaging such as the Home Assistant add-on image. Nil means a native +// install where each component reports and updates independently. +func BundleFromEnv() *Bundle { + kind := os.Getenv("FTW_BUNDLE") + if kind == "" { + return nil + } + return &Bundle{Kind: kind, Version: os.Getenv("FTW_BUNDLE_VERSION")} +} diff --git a/go/internal/components/bundle_test.go b/go/internal/components/bundle_test.go new file mode 100644 index 00000000..62e89ca4 --- /dev/null +++ b/go/internal/components/bundle_test.go @@ -0,0 +1,20 @@ +package components + +import "testing" + +func TestBundleFromEnvUnsetMeansNativeInstall(t *testing.T) { + t.Setenv("FTW_BUNDLE", "") + t.Setenv("FTW_BUNDLE_VERSION", "0.1.0-beta.1") + if got := BundleFromEnv(); got != nil { + t.Fatalf("BundleFromEnv() = %+v, want nil without FTW_BUNDLE", got) + } +} + +func TestBundleFromEnvReadsKindAndVersion(t *testing.T) { + t.Setenv("FTW_BUNDLE", "home_assistant_addon") + t.Setenv("FTW_BUNDLE_VERSION", "0.1.0-beta.1") + got := BundleFromEnv() + if got == nil || got.Kind != "home_assistant_addon" || got.Version != "0.1.0-beta.1" { + t.Fatalf("BundleFromEnv() = %+v, want home_assistant_addon 0.1.0-beta.1", got) + } +} diff --git a/web/settings/tabs/system.js b/web/settings/tabs/system.js index 368ebb53..15305f79 100644 --- a/web/settings/tabs/system.js +++ b/web/settings/tabs/system.js @@ -52,6 +52,24 @@ }; } + // In a single-image bundle (the Home Assistant add-on) Core and the + // Optimizer ship and update together under one bundled FTW release, and + // the host platform owns update and rollback. Showing per-container + // versions and update buttons there would only mislead. + function bundleDisplay(d) { + d = d || {}; + var bundle = d.bundle || {}; + if (bundle.kind !== "home_assistant_addon") return null; + var ftwVersion = (d.core || {}).version || "dev"; + return { + ftwVersion: ftwVersion, + bundleVersion: bundle.version || "", + note: "FTW " + ftwVersion + " is bundled with the Home Assistant add-on" + + (bundle.version ? " " + bundle.version : "") + + ". Home Assistant manages updates and rollback.", + }; + } + function bar(percent) { var p = Math.max(0, Math.min(100, Number(percent) || 0)); return '
'; @@ -225,17 +243,34 @@ var planTime = optimizerState.lastPlanAtMs ? " Last plan: " + new Date(optimizerState.lastPlanAtMs).toLocaleString() + "." : ""; - el.innerHTML = - '
Core' + escHtml(core.version || "dev") + - ' · ' + escHtml(release.channel || "native") + 'safety
' + - '
Optimizer' + escHtml(optimizerState.label) + - '' + - ((previousImages.optimizer || (updateStatus.previous_image_id && updateStatus.component === "optimizer")) ? ' ' : '') + '
' + - (optimizerState.warning ? '' : '') + + var bundled = bundleDisplay(d); + var warningHTML = optimizerState.warning + ? '' + : ''; + var driversHTML = '
Drivershost API ' + escHtml(drivers.driver_host_api || drivers.host_api || 1) + ' · ' + active + - ' managed
' + - '
'; + ' managed'; + var actionHTML = '
'; + if (bundled) { + el.innerHTML = + '
FTW' + escHtml(bundled.ftwVersion) + + 'bundled
' + + warningHTML + + driversHTML + + '
' + escHtml(bundled.note) + '
' + + actionHTML; + } else { + el.innerHTML = + '
Core' + escHtml(core.version || "dev") + + ' · ' + escHtml(release.channel || "native") + 'safety
' + + '
Optimizer' + escHtml(optimizerState.label) + + '' + + ((previousImages.optimizer || (updateStatus.previous_image_id && updateStatus.component === "optimizer")) ? ' ' : '') + '
' + + warningHTML + + driversHTML + + actionHTML; + } var status = document.getElementById("sys-component-action"); var optimizerBtn = document.getElementById("sys-update-optimizer"); if (optimizerBtn) optimizerBtn.onclick = function () { @@ -275,5 +310,5 @@ window._systemStatusTimer = setInterval(refresh, 5000); }, }; - S.tabs.system._pure = { optimizerStatus: optimizerStatus }; + S.tabs.system._pure = { optimizerStatus: optimizerStatus, bundleDisplay: bundleDisplay }; })(); diff --git a/web/settings/tabs/system.test.mjs b/web/settings/tabs/system.test.mjs index bf428ca4..bc607058 100644 --- a/web/settings/tabs/system.test.mjs +++ b/web/settings/tabs/system.test.mjs @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; globalThis.window = {}; await import("./system.js"); -const { optimizerStatus } = globalThis.window.FTWSettings.tabs.system._pure; +const { optimizerStatus, bundleDisplay } = globalThis.window.FTWSettings.tabs.system._pure; describe("optimizerStatus", () => { it("shows the active Go fallback and its reason", () => { @@ -48,3 +48,29 @@ describe("optimizerStatus", () => { }); }); }); + +describe("bundleDisplay", () => { + it("keeps the per-component breakdown for native installs", () => { + assert.equal(bundleDisplay({ core: { version: "v1.10.0" } }), null); + assert.equal(bundleDisplay({ bundle: { kind: "something_else" }, core: { version: "v1.10.0" } }), null); + }); + + it("collapses the Home Assistant add-on to one bundled FTW version", () => { + const got = bundleDisplay({ + core: { version: "v1.10.0-beta.1" }, + optimizer: { configured: true, runtime: { version: "v1.3.2-beta.1" } }, + bundle: { kind: "home_assistant_addon", version: "0.1.0-beta.1" }, + }); + assert.equal(got.ftwVersion, "v1.10.0-beta.1"); + assert.equal(got.bundleVersion, "0.1.0-beta.1"); + assert.match(got.note, /FTW v1\.10\.0-beta\.1 is bundled with the Home Assistant add-on 0\.1\.0-beta\.1/); + assert.match(got.note, /Home Assistant manages updates and rollback/); + }); + + it("still names a bundled FTW version when the add-on version is unknown", () => { + const got = bundleDisplay({ bundle: { kind: "home_assistant_addon" } }); + assert.equal(got.ftwVersion, "dev"); + assert.equal(got.bundleVersion, ""); + assert.match(got.note, /bundled with the Home Assistant add-on\./); + }); +});