Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ha-addon-bundle-version.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2120,6 +2121,7 @@ func main() {
})
return nil
},
Bundle: components.BundleFromEnv(),
Version: Version,
}
srv := api.New(deps)
Expand Down
7 changes: 7 additions & 0 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions go/internal/api/api_components.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
46 changes: 46 additions & 0 deletions go/internal/api/api_components_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"testing"

"github.com/srcfl/ftw/go/internal/components"
"github.com/srcfl/ftw/go/internal/mpc"
)

Expand Down Expand Up @@ -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())
}
}
28 changes: 28 additions & 0 deletions go/internal/components/bundle.go
Original file line number Diff line number Diff line change
@@ -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")}
}
20 changes: 20 additions & 0 deletions go/internal/components/bundle_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
55 changes: 45 additions & 10 deletions web/settings/tabs/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<div class="sys-bar"><div class="sys-bar-fill" style="width:' + p.toFixed(1) + '%"></div></div>';
Expand Down Expand Up @@ -225,17 +243,34 @@
var planTime = optimizerState.lastPlanAtMs
? " Last plan: " + new Date(optimizerState.lastPlanAtMs).toLocaleString() + "."
: "";
el.innerHTML =
'<div class="sys-row"><span class="sys-label">Core</span><span>' + escHtml(core.version || "dev") +
' · ' + escHtml(release.channel || "native") + '</span><span class="sys-value">safety</span></div>' +
'<div class="sys-row"><span class="sys-label">Optimizer</span><span>' + escHtml(optimizerState.label) +
'</span><span><button class="btn-add" id="sys-update-optimizer" type="button">Update</button>' +
((previousImages.optimizer || (updateStatus.previous_image_id && updateStatus.component === "optimizer")) ? ' <button class="btn-add" id="sys-rollback-optimizer" type="button">Rollback</button>' : '') + '</span></div>' +
(optimizerState.warning ? '<div class="sys-alert" role="alert"><strong>' + escHtml(optimizerState.warning) + '</strong>' + escHtml(planTime) + '</div>' : '') +
var bundled = bundleDisplay(d);
var warningHTML = optimizerState.warning
? '<div class="sys-alert" role="alert"><strong>' + escHtml(optimizerState.warning) + '</strong>' + escHtml(planTime) + '</div>'
: '';
var driversHTML =
'<div class="sys-row"><span class="sys-label">Drivers</span><span>host API ' +
escHtml(drivers.driver_host_api || drivers.host_api || 1) + ' · ' + active +
' managed</span><button class="btn-add" id="sys-refresh-drivers" type="button">Refresh</button></div>' +
'<div class="sys-meta" id="sys-component-action" style="grid-column:1/-1"></div>';
' managed</span><button class="btn-add" id="sys-refresh-drivers" type="button">Refresh</button></div>';
var actionHTML = '<div class="sys-meta" id="sys-component-action" style="grid-column:1/-1"></div>';
if (bundled) {
el.innerHTML =
'<div class="sys-row"><span class="sys-label">FTW</span><span>' + escHtml(bundled.ftwVersion) +
'</span><span class="sys-value">bundled</span></div>' +
warningHTML +
driversHTML +
'<div class="sys-meta" style="grid-column:1/-1">' + escHtml(bundled.note) + '</div>' +
actionHTML;
} else {
el.innerHTML =
'<div class="sys-row"><span class="sys-label">Core</span><span>' + escHtml(core.version || "dev") +
' · ' + escHtml(release.channel || "native") + '</span><span class="sys-value">safety</span></div>' +
'<div class="sys-row"><span class="sys-label">Optimizer</span><span>' + escHtml(optimizerState.label) +
'</span><span><button class="btn-add" id="sys-update-optimizer" type="button">Update</button>' +
((previousImages.optimizer || (updateStatus.previous_image_id && updateStatus.component === "optimizer")) ? ' <button class="btn-add" id="sys-rollback-optimizer" type="button">Rollback</button>' : '') + '</span></div>' +
warningHTML +
driversHTML +
actionHTML;
}
var status = document.getElementById("sys-component-action");
var optimizerBtn = document.getElementById("sys-update-optimizer");
if (optimizerBtn) optimizerBtn.onclick = function () {
Expand Down Expand Up @@ -275,5 +310,5 @@
window._systemStatusTimer = setInterval(refresh, 5000);
},
};
S.tabs.system._pure = { optimizerStatus: optimizerStatus };
S.tabs.system._pure = { optimizerStatus: optimizerStatus, bundleDisplay: bundleDisplay };
})();
28 changes: 27 additions & 1 deletion web/settings/tabs/system.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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\./);
});
});