feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
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:
@@ -50,7 +50,6 @@ class FakeHomeAssistant:
|
||||
self.admin = True
|
||||
self.reject_install = None
|
||||
self.cut_off = False
|
||||
self.no_metrics = False
|
||||
self.entities = {
|
||||
ADDON: self._update("on", "1.0"),
|
||||
"sensor.backup_last_successful_automatic_backup": {"state": BEFORE, "attributes": {}},
|
||||
@@ -85,10 +84,6 @@ class FakeHomeAssistant:
|
||||
self.tokens.add(request.headers.get("authorization"))
|
||||
if self.refuse:
|
||||
return httpx.Response(401)
|
||||
if path == "/api/prometheus":
|
||||
if self.no_metrics:
|
||||
return httpx.Response(404)
|
||||
return httpx.Response(200, text="# TYPE homeassistant_entity_x gauge\nhomeassistant_entity_x 1\n")
|
||||
if path == "/api/template":
|
||||
return httpx.Response(200, text="1") if self.admin else httpx.Response(401)
|
||||
if path.startswith("/api/services/"):
|
||||
@@ -119,6 +114,7 @@ async def _clean(app, **connection):
|
||||
async with s.begin():
|
||||
await s.execute(delete(m.HomeAssistantUpdate))
|
||||
await s.execute(delete(m.HomeAssistantConnection))
|
||||
await s.execute(delete(m.HomeAssistantSchedule))
|
||||
s.add(m.HomeAssistantConnection(**{
|
||||
"url": "http://ha.test:8123", "client_id": "http://steward.test/",
|
||||
"refresh_token": encrypt_secret(f"refresh-{uuid.uuid4().hex}"), "can_install": True, **connection}))
|
||||
@@ -471,164 +467,214 @@ def test_signing_in_again_ends_the_sign_in_it_replaces(app, monkeypatch):
|
||||
assert ha.revoked == ["refresh-old"] and not connection.refused and connection.last_polled_at is None
|
||||
|
||||
|
||||
# ── Its metrics, under the one sign-in ───────────────────────────────────────
|
||||
# ── The system, over the websocket ───────────────────────────────────────────
|
||||
|
||||
def _prom(sub):
|
||||
from steward.core.plugin_manager import loaded_plugin_submodule
|
||||
return loaded_plugin_submodule("prometheus", sub)
|
||||
class FakeSocket:
|
||||
"""Home Assistant's websocket: `answers` by question type or Supervisor endpoint,
|
||||
where one left out is refused as it is for a non-administrator."""
|
||||
|
||||
def __init__(self, answers, auth="auth_ok"):
|
||||
self.answers, self.auth = answers, auth
|
||||
self.waiting = [{"type": "auth_required"}]
|
||||
self.asked = []
|
||||
|
||||
def __call__(self, url, **options):
|
||||
self.url = url
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def send(self, text):
|
||||
question = json.loads(text)
|
||||
if question["type"] == "auth":
|
||||
self.waiting.append({"type": self.auth})
|
||||
return
|
||||
key = question.get("endpoint") or question["type"]
|
||||
self.asked.append(key)
|
||||
# Something unasked arriving first must be passed over.
|
||||
self.waiting.append({"type": "event", "id": 9999})
|
||||
self.waiting.append({"id": question["id"], "success": key in self.answers, "result": self.answers.get(key)})
|
||||
|
||||
async def recv(self):
|
||||
return json.dumps(self.waiting.pop(0))
|
||||
|
||||
|
||||
METRICS_URL = "http://ha.test:8123/api/prometheus"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signed_in(app, monkeypatch):
|
||||
"""A fake Home Assistant behind both the plugin's client and the scraper."""
|
||||
client, scraper = _ha("client"), _prom("scraper")
|
||||
def test_a_poll_keeps_the_system_problems_and_records_them(app):
|
||||
from steward.models.metrics import PluginMetricLatest
|
||||
sched = _ha("scheduler")
|
||||
ha = FakeHomeAssistant()
|
||||
real_token, real_scrape = client.access_token, scraper.scrape
|
||||
monkeypatch.setattr(client, "access_token", lambda c: real_token(c, ha.transport))
|
||||
monkeypatch.setattr(scraper, "scrape", lambda url, token, verify: real_scrape(url, token, verify, ha.transport))
|
||||
return ha
|
||||
socket = FakeSocket({
|
||||
"get_config": {"recovery_mode": False},
|
||||
"repairs/list_issues": {"issues": [{"domain": "hassio", "translation_key": "issue_x", "severity": "warning"}]},
|
||||
"frontend/get_translations": {"resources": {"component.hassio.issues.issue_x.title": "Something's wrong"}},
|
||||
"/resolution/info": {"unhealthy": [], "unsupported": []},
|
||||
"/host/info": {"disk_free": 50.0, "disk_total": 100.0},
|
||||
"/addons": {"addons": [{"slug": "a", "state": "started"}, {"slug": "b", "name": "B", "state": "stopped"}]},
|
||||
"/addons/b/info": {"boot": "auto"},
|
||||
})
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await sched.poll(app, transport=ha.transport, connect=socket)
|
||||
connection, _ = await _state(app)
|
||||
async with app.db_sessionmaker() as s:
|
||||
rows = (await s.execute(select(PluginMetricLatest).where(
|
||||
PluginMetricLatest.source_module == "homeassistant"))).scalars().all()
|
||||
return connection, {r.metric_name: r.value for r in rows}
|
||||
|
||||
connection, metrics = asyncio.run(go())
|
||||
assert socket.url == "ws://ha.test:8123/api/websocket"
|
||||
assert "/addons/a/info" not in socket.asked # a running add-on isn't asked after
|
||||
assert [p["title"] for p in connection.health["problems"]] == ["Something's wrong", "B should start on its own and isn't running."]
|
||||
assert metrics["problems_open"] == 2.0 and metrics["disk_free_pct"] == 50.0 and metrics["update_failed"] == 0.0
|
||||
|
||||
|
||||
async def _no_targets(app):
|
||||
T = _prom("models").PrometheusTarget
|
||||
def test_a_report_it_couldnt_read_leaves_the_last_one_and_home_assistant_still_answers(app):
|
||||
sched = _ha("scheduler")
|
||||
ha = FakeHomeAssistant()
|
||||
|
||||
def unreachable(url, **options):
|
||||
raise OSError("connection refused")
|
||||
|
||||
async def go():
|
||||
await _clean(app, health={"repairs_read": True, "problems": [{"title": "Old"}]})
|
||||
await sched.poll(app, transport=ha.transport, connect=unreachable)
|
||||
return (await _state(app))[0]
|
||||
|
||||
connection = asyncio.run(go())
|
||||
assert connection.last_error is None and not connection.refused
|
||||
assert connection.health["problems"] == [{"title": "Old"}] and "connection refused" in connection.health["error"]
|
||||
|
||||
|
||||
def test_a_refused_websocket_stops_the_polling_like_any_refusal(app):
|
||||
sched = _ha("scheduler")
|
||||
ha = FakeHomeAssistant()
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}, auth="auth_invalid"))
|
||||
return (await _state(app))[0]
|
||||
|
||||
connection = asyncio.run(go())
|
||||
assert connection.refused and "Sign in again" in connection.last_error
|
||||
|
||||
|
||||
# ── Updates on a schedule ────────────────────────────────────────────────────
|
||||
|
||||
CORE = "update.home_assistant_core_update"
|
||||
|
||||
|
||||
async def _a_slot_just_passed(app, minutes=10):
|
||||
from datetime import timedelta
|
||||
m = _ha("models")
|
||||
at = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(delete(T).where(T.url.like("%ha.test%") | (T.name == "HomeAssistant")))
|
||||
s.add(m.HomeAssistantSchedule(enabled=True, weekday=at.weekday(), at_time=f"{at:%H:%M}", time_zone="UTC",
|
||||
window_minutes=240, created_at=at - timedelta(days=30)))
|
||||
|
||||
|
||||
async def _the_target(app):
|
||||
T = _prom("models").PrometheusTarget
|
||||
async def _schedule_row(app):
|
||||
m = _ha("models")
|
||||
async with app.db_sessionmaker() as s:
|
||||
return (await s.execute(select(T).where(T.url.like("%ha.test%")))).scalars().first()
|
||||
return (await s.execute(select(m.HomeAssistantSchedule))).scalars().first()
|
||||
|
||||
|
||||
def test_reading_its_metrics_makes_a_target_that_scrapes_with_the_sign_in(app, signed_in):
|
||||
sched = _prom("scheduler")
|
||||
sent = []
|
||||
|
||||
async def fake_scrape(url, token, verify_tls):
|
||||
sent.append((url, token))
|
||||
return await _prom("scraper").scrape(url, token, verify_tls)
|
||||
def test_a_slot_installs_the_add_on_first_then_core_and_stops_at_a_failure(app):
|
||||
sched, plan = _ha("scheduler"), _ha("schedule")
|
||||
m = _ha("models")
|
||||
ha = FakeHomeAssistant()
|
||||
ha.entities[CORE] = ha._update("on", "1.0")
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await _no_targets(app)
|
||||
web, _ = await _admin_client(app)
|
||||
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
||||
assert "Read its metrics" in page
|
||||
response = await web.post("/plugins/homeassistant/metrics", headers=CSRF)
|
||||
assert response.status_code == 302
|
||||
await sched.scrape_due(app, scrape=fake_scrape)
|
||||
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
||||
assert "Read with this sign-in" in page
|
||||
return await _the_target(app)
|
||||
|
||||
target = asyncio.run(go())
|
||||
assert target.name == "HomeAssistant" and target.token == "" and target.token_from == "homeassistant"
|
||||
assert "python_*" in target.drop and target.last_error is None and target.last_ok_at
|
||||
assert sent == [(METRICS_URL, "access-1")]
|
||||
|
||||
|
||||
def test_using_the_sign_in_moves_a_target_and_keeps_its_name_and_filters(app, signed_in):
|
||||
from steward.core.crypto import encrypt_secret
|
||||
T = _prom("models").PrometheusTarget
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await _no_targets(app)
|
||||
await _a_slot_just_passed(app)
|
||||
await plan.step(app, ha.transport) # the slot fires and asks for a fresh read
|
||||
assert not (await _state(app))[1] and (await _state(app))[0].last_polled_at is None
|
||||
await plan.step(app, ha.transport) # nothing starts before that read
|
||||
assert not (await _state(app))[1]
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await plan.step(app, ha.transport)
|
||||
[first] = (await _state(app))[1]
|
||||
assert first.entity_id == ADDON and first.started_by == "schedule"
|
||||
await plan.step(app, ha.transport) # one at a time
|
||||
assert len((await _state(app))[1]) == 1
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(T(name="my-house", url=METRICS_URL, token=encrypt_secret("dead-token"), keep=["a_*"], refused=True))
|
||||
web, _ = await _admin_client(app)
|
||||
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
||||
assert "Use this sign-in" in page
|
||||
assert (await web.post("/plugins/homeassistant/metrics", headers=CSRF)).status_code == 302
|
||||
target = await _the_target(app)
|
||||
await _no_targets(app)
|
||||
return target
|
||||
run = await s.get(m.HomeAssistantUpdate, first.id)
|
||||
run.state, run.message = "failed", "It broke."
|
||||
await plan.step(app, ha.transport)
|
||||
assert "It broke." in (await _schedule_row(app)).stopped
|
||||
await plan.step(app, ha.transport)
|
||||
return (await _state(app))[1]
|
||||
|
||||
target = asyncio.run(go())
|
||||
assert (target.name, target.keep) == ("my-house", ["a_*"])
|
||||
assert target.token == "" and target.token_from == "homeassistant" and not target.refused
|
||||
assert [r.entity_id for r in asyncio.run(go())] == [ADDON]
|
||||
assert ha.calls == [BACKUP]
|
||||
|
||||
|
||||
def test_metrics_home_assistant_doesnt_serve_change_nothing_and_say_how(app, signed_in):
|
||||
signed_in.no_metrics = True
|
||||
def test_a_slot_moves_on_after_a_good_update_and_never_retries_one(app):
|
||||
sched, plan, updates = _ha("scheduler"), _ha("schedule"), _ha("updates")
|
||||
ha = FakeHomeAssistant()
|
||||
ha.entities[CORE] = ha._update("on", "1.0")
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await _no_targets(app)
|
||||
web, _ = await _admin_client(app)
|
||||
response = await web.post("/plugins/homeassistant/metrics", headers=CSRF)
|
||||
return response.status_code, await response.get_data(as_text=True), await _the_target(app)
|
||||
await _a_slot_just_passed(app)
|
||||
await plan.step(app, ha.transport)
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await plan.step(app, ha.transport)
|
||||
ha.backup_lands()
|
||||
await updates.advance(app, ha.transport) # the backup landed
|
||||
await updates.advance(app, ha.transport) # the install is sent
|
||||
ha.update_lands()
|
||||
await updates.advance(app, ha.transport) # it's done, and asks for a fresh read
|
||||
await plan.step(app, ha.transport)
|
||||
assert len((await _state(app))[1]) == 1
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await plan.step(app, ha.transport)
|
||||
runs = (await _state(app))[1]
|
||||
return {r.entity_id: r.state for r in runs}
|
||||
|
||||
status, page, target = asyncio.run(go())
|
||||
assert status == 409 and "configuration.yaml" in page and target is None
|
||||
assert asyncio.run(go()) == {ADDON: "done", CORE: "backing_up"}
|
||||
|
||||
|
||||
def test_a_refused_scrape_stops_the_sign_in_and_try_again_starts_both(app, signed_in):
|
||||
sched, metrics = _prom("scheduler"), _ha("metrics")
|
||||
TokenRefused = _prom("scraper").TokenRefused
|
||||
T = _prom("models").PrometheusTarget
|
||||
asked = []
|
||||
|
||||
async def refusing(url, token, verify_tls):
|
||||
asked.append(url)
|
||||
raise TokenRefused("Answered 401: the token was refused.")
|
||||
|
||||
async def overdue():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
row = await s.get(T, (await _the_target(app)).id)
|
||||
row.last_scraped_at = row.last_scraped_at.replace(year=2000)
|
||||
def test_a_slot_that_cant_start_an_update_says_why_and_asks_no_more(app):
|
||||
sched, plan = _ha("scheduler"), _ha("schedule")
|
||||
ha = FakeHomeAssistant()
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await _no_targets(app)
|
||||
web, _ = await _admin_client(app)
|
||||
await web.post("/plugins/homeassistant/metrics", headers=CSRF)
|
||||
await sched.scrape_due(app, scrape=refusing)
|
||||
connection = (await _state(app))[0]
|
||||
assert connection.refused and connection.last_error == metrics.REFUSED
|
||||
await _a_slot_just_passed(app)
|
||||
await plan.step(app, ha.transport)
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
del ha.entities["sensor.backup_last_successful_automatic_backup"]
|
||||
await plan.step(app, ha.transport)
|
||||
return await _schedule_row(app)
|
||||
|
||||
# Nothing more is sent, by the scrape or the poll, and the target says why.
|
||||
await overdue()
|
||||
await sched.scrape_due(app, scrape=refusing)
|
||||
assert len(asked) == 1
|
||||
target = await _the_target(app)
|
||||
assert not target.refused and "Try again" in target.last_error
|
||||
|
||||
assert (await web.post("/plugins/homeassistant/connection/retry", headers=CSRF)).status_code == 302
|
||||
await overdue()
|
||||
await sched.scrape_due(app, scrape=refusing)
|
||||
assert len(asked) == 2
|
||||
await _no_targets(app)
|
||||
|
||||
asyncio.run(go())
|
||||
row = asyncio.run(go())
|
||||
assert "wasn't started" in row.stopped and "no automatic backups" in row.stopped and not ha.calls
|
||||
|
||||
|
||||
def test_the_sign_in_isnt_sent_to_another_address(app, signed_in):
|
||||
sched = _prom("scheduler")
|
||||
T = _prom("models").PrometheusTarget
|
||||
asked = []
|
||||
|
||||
async def fake_scrape(url, token, verify_tls):
|
||||
asked.append(url)
|
||||
return {}
|
||||
def test_a_slot_past_its_window_starts_nothing_and_a_new_schedule_waits_for_its_next(app):
|
||||
sched, plan = _ha("scheduler"), _ha("schedule")
|
||||
ha = FakeHomeAssistant()
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await _no_targets(app)
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(T(name="elsewhere", url="http://elsewhere.ha.test/metrics", token_from="homeassistant"))
|
||||
await sched.scrape_due(app, scrape=fake_scrape)
|
||||
target = await _the_target(app)
|
||||
await _no_targets(app)
|
||||
return target
|
||||
await _a_slot_just_passed(app, minutes=300)
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await plan.step(app, ha.transport)
|
||||
assert not (await _state(app))[1]
|
||||
await _clean(app)
|
||||
at = datetime.now(timezone.utc)
|
||||
await plan.save(app, enabled=True, weekday=at.weekday(), at_time="00:00", time_zone="UTC")
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await plan.step(app, ha.transport)
|
||||
return (await _state(app))[1]
|
||||
|
||||
target = asyncio.run(go())
|
||||
assert asked == [] and "isn't the address" in target.last_error and signed_in.refreshes == []
|
||||
assert asyncio.run(go()) == []
|
||||
assert ha.calls == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")):
|
||||
|
||||
Reference in New Issue
Block a user