feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m54s
CI / publish (push) Skipped

The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.

A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.

The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 14:16:42 -05:00
co-authored by Claude Opus 5.5
parent 4c9ed02652
commit f7a856cb90
31 changed files with 835 additions and 503 deletions
+75 -4
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import plugins.homeassistant as homeassistant_plugin
from plugins.homeassistant import mcp_tools, scheduler, service as s
from plugins.homeassistant import mcp_tools, schedule, scheduler, service as s
from steward.models.users import UserRole
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
@@ -86,7 +86,7 @@ def _connection(**over):
"verify_tls": True, "signed_in_by": "someone", "signed_in_at": NOW,
"refused": False, "last_error": None, "last_polled_at": NOW, "last_ok_at": NOW,
"backup": {"known": True, "last_ok": NOW.isoformat(), "last_result": "completed"},
"updates": [], **over})
"updates": [], "health": {}, **over})
def test_the_overview_names_what_needs_a_look_and_no_more():
@@ -101,6 +101,50 @@ def test_the_overview_names_what_needs_a_look_and_no_more():
failed = NS(state="failed", title="Core", message="No new backup.", finished_at=NOW)
[f] = mcp_tools.overview_findings(_connection(), failed, NOW)
assert f["kind"] == "home_assistant_update_failed" and "No new backup." in f["summary"]
two = {"problems": [{"title": "Disk trouble"}, {"title": "Another"}]}
[f] = mcp_tools.overview_findings(_connection(health=two), None, NOW)
assert f["kind"] == "home_assistant_problem" and f["summary"] == "Disk trouble (and 1 more)"
plan = NS(stopped="Core: it failed.", last_slot_at=None)
[f] = mcp_tools.overview_findings(_connection(), None, NOW, plan)
assert f["kind"] == "home_assistant_schedule_stopped" and "Core: it failed." in f["summary"]
def test_system_problems_are_read_from_what_home_assistant_answers():
healthy = s.read_health({"config": {"recovery_mode": False}, "issues": [], "resolution": {"unhealthy": [], "unsupported": []},
"host": {"disk_free": 99.8, "disk_total": 116.5}, "addons": [{"slug": "a", "state": "started"}]})
assert healthy["problems"] == [] and healthy["repairs_read"] and healthy["supervised"]
assert healthy["disk_free_pct"] == 85.7
bad = s.read_health({
"config": {"recovery_mode": True},
"issues": [{"domain": "hassio", "translation_key": "issue_system_reboot_required", "severity": "warning"},
{"domain": "demo", "translation_key": "named", "translation_placeholders": {"who": "X"}, "severity": "critical"},
{"domain": "demo", "translation_key": "gone", "ignored": True},
{"domain": "demo", "translation_key": "old", "dismissed_version": "1.0"}],
"titles": {"component.demo.issues.named.title": "{who} broke"},
"resolution": {"unhealthy": ["docker_gateway"], "unsupported": []},
"host": {"disk_free": 5.0, "disk_total": 100.0},
"addons": [{"slug": "should", "name": "Should run", "state": "stopped"}, {"slug": "once", "state": "stopped"}],
"boots": {"should": "auto", "once": "manual"}})
assert [p["kind"] for p in bad["problems"]] == ["recovery", "repair", "repair", "supervisor", "disk", "addon"]
titles = [p["title"] for p in bad["problems"]]
assert titles[1] == "System reboot required" and titles[2] == "X broke" and "docker gateway" in titles[3]
assert "5 GB of 100 GB" in titles[4] and "Should run" in titles[5]
def test_what_home_assistant_wouldnt_answer_is_not_read_as_fine():
found = s.read_health({"config": {}, "issues": None, "resolution": None, "host": None, "addons": None})
assert not found["repairs_read"] and not found["supervised"] and found["disk_free_pct"] is None
def test_a_schedule_installs_add_ons_then_core_then_the_os():
def update(entity_id, kind, in_progress=False):
return {"entity_id": entity_id, "kind": kind, "in_progress": in_progress}
waiting = [update("os", "os"), update("core", "core"), update("addon", "addon")]
assert schedule.next_to_install(waiting, set())["entity_id"] == "addon"
assert schedule.next_to_install(waiting, {"addon"})["entity_id"] == "core"
assert schedule.next_to_install(waiting, {"addon", "core"})["entity_id"] == "os"
assert schedule.next_to_install(waiting, {"addon", "core", "os"}) is None
assert schedule.next_to_install([update("addon", "addon", in_progress=True)], set()) is None
def test_reading_is_open_to_a_viewer_and_installing_is_for_an_admin():
@@ -109,7 +153,8 @@ def test_reading_is_open_to_a_viewer_and_installing_is_for_an_admin():
def register(fn, *, role, read_only): # both required, as on the server
seen[fn.__name__] = (role, read_only)
homeassistant_plugin.register_mcp_tools(register)
assert seen == {"get_home_assistant": (UserRole.viewer, True), "update_home_assistant": (UserRole.admin, False)}
assert seen == {"get_home_assistant": (UserRole.viewer, True), "update_home_assistant": (UserRole.admin, False),
"set_home_assistant_schedule": (UserRole.admin, False)}
def _page_values(**over):
@@ -119,7 +164,9 @@ def _page_values(**over):
return {"connection": _connection(), "reach": "up", "says": "Answering.", "installable": [update], "others": [],
"current": [], "runs": [], "under_way": None, "backup": {}, "backup_tone": "ok",
"backup_says": "Backups are up to date.", "backup_at": NOW, "backup_next": None, "backup_age": 1.0,
"stale_hours": 26, **over}
"stale_hours": 26, "health": {"repairs_read": True, "supervised": True}, "problems": [], "plan": None,
"plan_when": None, "plan_next": None, "weekdays": ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday"), "default_zone": "UTC", **over}
async def _render(app, role, **values):
@@ -151,6 +198,30 @@ async def test_install_is_held_back_while_it_couldnt_work_and_the_page_says_why(
assert "isn't an administrator" in status_only
async def test_the_page_shows_system_problems_and_what_it_couldnt_read(app):
clear = await _render(app, "viewer", **_page_values())
assert "No repairs open" in clear
problem = {"kind": "repair", "severity": "warning", "title": "System reboot required"}
shown = await _render(app, "viewer", **_page_values(problems=[problem]))
assert "System reboot required" in shown and "Settings → System → Repairs" in shown
hidden = await _render(app, "viewer", **_page_values(health={"repairs_read": False, "supervised": False}))
assert "wouldn't share its repairs" in hidden
bare = await _render(app, "viewer", **_page_values(health={"repairs_read": True, "supervised": False}))
assert "No Supervisor answered" in bare
async def test_an_admin_sets_the_schedule_warned_of_what_it_does_and_a_viewer_only_reads_it(app):
off = await _render(app, "admin", **_page_values())
assert 'action="/plugins/homeassistant/schedule"' in off and "Off. Updates are only installed" in off
assert "with nobody there to watch" in off
assert '<option value="5" selected>Saturday</option>' in off and 'value="05:00"' in off
plan = NS(enabled=True, weekday=5, at_time="05:00", time_zone="Europe/London", stopped="Core: it failed.")
values = _page_values(plan=plan, plan_when="Saturdays at 05:00 (Europe/London), up to 4 h", plan_next=NOW)
on = await _render(app, "viewer", **values)
assert "Saturdays at 05:00" in on and "Core: it failed." in on
assert 'action="/plugins/homeassistant/schedule"' not in on
async def test_an_unconnected_page_offers_to_connect(app):
page = await _render(app, "admin", connection=None)
assert 'href="/plugins/homeassistant/connection"' in page
-4
View File
@@ -141,10 +141,6 @@ def test_the_form_checks_what_it_stores_and_keeps_a_blank_token():
assert again["token"] == "enc:v1:kept" and "enabled" not in again
cleared = parse_form(_Form(name="Archives", url="http://a/metrics", clear_token="on"), existing)
assert cleared["token"] == ""
lent = PrometheusTarget(name="Archives", url="http://a/metrics", token="", token_from="a_plugin")
assert parse_form(_Form(name="Archives", url="http://a/metrics"), lent)["token_from"] == "a_plugin"
typed = parse_form(_Form(name="Archives", url="http://a/metrics", token="s3cret"), lent)
assert typed["token_from"] is None and typed["token"]
for bad in (dict(name="", url="http://a"), dict(name="a:b", url="http://a"),
dict(name="a", url="ftp://a"), dict(name="a", url="http://a", interval_seconds="5"),
dict(name="a", url="http://a", series_cap="0")):