Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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\./);
});
});