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 = - '