fix(homeassistant): storage reads used of total with its percentage, and an OS update is said to wait on a reboot (#6753, #6754)
The tile shows Home Assistant's own used figure beside the total, so it matches Home Assistant's storage page. An OS update doesn't reboot the machine: Supervisor writes the image, reports the new version and raises a repair asking for a reboot. The confirmation, schedule warning, MCP docs and plugin docs now say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ Install asks once, then Steward:
|
||||
|
||||
One runs at a time, and it carries on if Steward restarts. Firmware and custom integrations are left to Home Assistant, and Supervisor updates itself.
|
||||
|
||||
An OS update takes effect only after the machine reboots, and Steward leaves that to you. Steward records the update as done once Home Assistant reports the new version, which it does before the reboot. Home Assistant then raises a repair asking for the reboot, which appears under System problems until you do it.
|
||||
|
||||
## System problems
|
||||
|
||||
Each read also asks Home Assistant whether the system itself has a problem, and the page and the overview show what it says:
|
||||
@@ -43,7 +45,7 @@ Repairs need an administrator's sign-in. Disk space and add-ons come from the Su
|
||||
|
||||
## Scheduled updates
|
||||
|
||||
Set a day and time under Scheduled updates on the page. It's off until you do. At that time Steward installs what's waiting, add-ons first, then Core, then the OS, one at a time and each after its own confirmed backup. It starts nothing new more than 4 hours after the slot. The first failure ends the run and the rest wait, and the page, the overview and `update_failed` say so. A Core update restarts Home Assistant and an OS update reboots the machine, with nobody there to watch, and an update can change how automations behave.
|
||||
Set a day and time under Scheduled updates on the page. It's off until you do. At that time Steward installs what's waiting, add-ons first, then Core, then the OS, one at a time and each after its own confirmed backup. It starts nothing new more than 4 hours after the slot. The first failure ends the run and the rest wait, and the page, the overview and `update_failed` say so. A Core update restarts Home Assistant with nobody there to watch, an update can change how automations behave, and an OS update still needs you to reboot the machine afterwards.
|
||||
|
||||
## Metrics
|
||||
|
||||
|
||||
@@ -82,8 +82,9 @@ async def update_home_assistant(entity_id: str) -> dict:
|
||||
|
||||
entity_id: from get_home_assistant's `installable`.
|
||||
|
||||
An update restarts Home Assistant or the add-on, and an OS update reboots the
|
||||
machine. Refused while another is under way, or if nothing is waiting there.
|
||||
An update restarts Home Assistant or the add-on. An OS update reads as done
|
||||
once it's written, and takes effect after a reboot, which Steward leaves to a
|
||||
person. Refused while another is under way, or if nothing is waiting there.
|
||||
Admin only, as on the page. Recorded in the audit log.
|
||||
"""
|
||||
caller = current_caller()
|
||||
@@ -104,8 +105,9 @@ async def set_home_assistant_schedule(enabled: bool, weekday: int = 5, at_time:
|
||||
|
||||
At each slot Steward installs add-ons, then Core, then the OS, one at a time,
|
||||
each after a confirmed backup, stopping at the first failure. A Core update
|
||||
restarts Home Assistant and an OS update reboots the machine, with nobody
|
||||
watching. The first slot is the next one from now. Admin only, and audited.
|
||||
restarts Home Assistant with nobody watching. An OS update takes effect after
|
||||
a reboot, which Steward leaves to a person, and `system.problems` shows it until
|
||||
then. The first slot is the next one from now. Admin only, and audited.
|
||||
"""
|
||||
try:
|
||||
weekly.parse_at(at_time)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# plugins/homeassistant/schedule.py
|
||||
"""Installing what's waiting at a weekly slot: one at a time through the same path
|
||||
as the page's Install, each after its own confirmed backup. Add-ons go first and
|
||||
the OS last, as it reboots the machine. The first failure ends the slot, leaving the
|
||||
the OS last, as it waits on a reboot. The first failure ends the slot, leaving the
|
||||
rest for someone to look at. Everything is on the rows, so a restart of Steward or
|
||||
of Home Assistant only delays the next step."""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -141,8 +141,10 @@ def read_health(found: dict) -> dict:
|
||||
problems.append({"kind": "supervisor", "severity": "error" if word == "unhealthy" else "warning",
|
||||
"title": f"The Supervisor calls this system {word}: {str(reason).replace('_', ' ')}."})
|
||||
host = found.get("host") or {}
|
||||
free, total = host.get("disk_free"), host.get("disk_total")
|
||||
free, used, total = host.get("disk_free"), host.get("disk_used"), host.get("disk_total")
|
||||
free_pct = round(100 * free / total, 1) if free is not None and total else None
|
||||
# Used and free don't add up to the total: the system reserves some of the disk.
|
||||
used_pct = round(100 * used / total, 1) if used is not None and total else None
|
||||
if free_pct is not None and free_pct < LOW_DISK_PCT:
|
||||
problems.append({"kind": "disk", "severity": "error", "title": f"Only {free:g} GB of {total:g} GB free."})
|
||||
boots = found.get("boots") or {}
|
||||
@@ -151,7 +153,8 @@ def read_health(found: dict) -> dict:
|
||||
problems.append({"kind": "addon", "severity": "error",
|
||||
"title": f"{addon.get('name') or addon.get('slug')} should start on its own and isn't running."})
|
||||
return {"repairs_read": issues is not None, "supervised": found.get("resolution") is not None, "problems": problems,
|
||||
"disk_free_gb": free, "disk_total_gb": total, "disk_free_pct": free_pct}
|
||||
"disk_free_gb": free, "disk_used_gb": used, "disk_total_gb": total,
|
||||
"disk_free_pct": free_pct, "disk_used_pct": used_pct}
|
||||
|
||||
|
||||
def problems_open(health: dict | None) -> list[dict]:
|
||||
|
||||
@@ -37,8 +37,7 @@
|
||||
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("System problems", problems|length if health.repairs_read else "—", state="degraded" if problems else none) }}
|
||||
{{ stat_tile("Storage", (health.disk_total_gb ~ " GB") if health.disk_total_gb else "—",
|
||||
hint=("%.0f%% full"|format(100 - health.disk_free_pct)) if health.disk_free_pct is number else none) }}
|
||||
{{ stat_tile("Storage", ("%s of %s GB (%.0f%%)"|format(health.disk_used_gb, health.disk_total_gb, health.disk_used_pct)) if health.disk_used_pct is number else "—") }}
|
||||
{{ stat_tile("Updates waiting", installable|length if connection.last_ok_at else "—") }}
|
||||
{{ stat_tile("Last good backup", (backup_at|ago) if backup_at else "—", state="degraded" if backup_tone == 'warn' else none) }}
|
||||
{{ stat_tile("Next backup", (backup_next|ago) if backup_next else "—") }}
|
||||
@@ -78,7 +77,7 @@
|
||||
{% if admin %}<td style="text-align:right;">
|
||||
{% if connection.can_install and not under_way and not connection.refused and not u.in_progress %}
|
||||
<form method="post" action="/plugins/homeassistant/install"
|
||||
data-confirm="Install {{ u.title }} {{ u.latest }}? Steward takes a backup first and won't install without it. {{ 'Home Assistant restarts.' if u.kind == 'core' else ('The machine reboots.' if u.kind == 'os' else 'The add-on restarts.') }}">{{ csrf_field() }}
|
||||
data-confirm="Install {{ u.title }} {{ u.latest }}? Steward takes a backup first and won't install without it. {{ 'Home Assistant restarts.' if u.kind == 'core' else ('It takes effect once you reboot the machine, which Steward leaves to you.' if u.kind == 'os' else 'The add-on restarts.') }}">{{ csrf_field() }}
|
||||
<input type="hidden" name="entity_id" value="{{ u.entity_id }}">
|
||||
<button class="btn btn-sm" type="submit">Install</button>
|
||||
</form>
|
||||
@@ -104,7 +103,7 @@
|
||||
{% endif %}
|
||||
{% if plan and plan.stopped %}<p class="tone-warn">The last run stopped early. {{ plan.stopped }}</p>{% endif %}
|
||||
{% if admin %}
|
||||
<p class="tone-warn">A Core update restarts Home Assistant and an OS update reboots the machine, with nobody there to watch. An update can also change how automations behave.</p>
|
||||
<p class="tone-warn">A Core update restarts Home Assistant with nobody there to watch, and an update can change how automations behave. An OS update takes effect only once you reboot the machine: Steward leaves that to you, and shows it under System until it's done.</p>
|
||||
<form method="post" action="/plugins/homeassistant/schedule" style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
|
||||
<label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.4rem;">
|
||||
<input type="checkbox" name="enabled" value="on"{% if plan and plan.enabled %} checked{% endif %}> Install on a schedule
|
||||
|
||||
@@ -19,7 +19,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
UNDER_WAY = ("backing_up", "installing")
|
||||
BACKUP_DEADLINE_S = 30 * 60
|
||||
# An OS update reboots the machine, and a Pi is slow to come back.
|
||||
# An OS update downloads and writes a whole system image, which is slow on a Pi. Home
|
||||
# Assistant reports the new version as soon as that's written, before the reboot that
|
||||
# makes it run, and raises a repair asking for that reboot.
|
||||
INSTALL_DEADLINE_S = {"addon": 20 * 60, "core": 30 * 60, "os": 45 * 60}
|
||||
|
||||
|
||||
|
||||
@@ -533,18 +533,29 @@ def test_a_poll_keeps_the_system_problems_and_records_them(app):
|
||||
assert metrics["problems_open"] == 2.0 and metrics["disk_free_pct"] == 50.0 and metrics["update_failed"] == 0.0
|
||||
|
||||
|
||||
def test_the_page_shows_the_devices_storage_and_how_full_it_is(app):
|
||||
def test_the_page_shows_storage_used_of_the_total_with_the_percentage(app):
|
||||
async def go():
|
||||
await _clean(app, health={"repairs_read": True, "problems": [], "disk_free_gb": 99.8,
|
||||
"disk_total_gb": 116.5, "disk_free_pct": 85.7})
|
||||
await _clean(app, health={"repairs_read": True, "problems": [], "disk_free_gb": 99.8, "disk_used_gb": 12.0,
|
||||
"disk_total_gb": 116.5, "disk_free_pct": 85.7, "disk_used_pct": 10.3})
|
||||
web, _ = await _admin_client(app)
|
||||
return await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
||||
|
||||
page = asyncio.run(go())
|
||||
assert "116.5 GB" in page and "14% full" in page
|
||||
assert "12.0 of 116.5 GB (10%)" in page
|
||||
assert "Disk free" not in page
|
||||
|
||||
|
||||
def test_the_page_says_an_os_update_waits_on_a_reboot_rather_than_making_one(app):
|
||||
async def go():
|
||||
await _clean(app)
|
||||
web, _ = await _admin_client(app)
|
||||
return await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
||||
|
||||
page = asyncio.run(go())
|
||||
assert "takes effect only once you reboot the machine" in page
|
||||
assert "reboots the machine" not in page
|
||||
|
||||
|
||||
def test_a_report_it_couldnt_read_leaves_the_last_one_and_home_assistant_still_answers(app):
|
||||
sched = _ha("scheduler")
|
||||
ha = FakeHomeAssistant()
|
||||
|
||||
@@ -111,9 +111,10 @@ def test_the_overview_names_what_needs_a_look_and_no_more():
|
||||
|
||||
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"}]})
|
||||
"host": {"disk_free": 99.8, "disk_used": 12.0, "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
|
||||
assert healthy["disk_free_pct"] == 85.7 and healthy["disk_used_gb"] == 12.0 and healthy["disk_used_pct"] == 10.3
|
||||
bad = s.read_health({
|
||||
"config": {"recovery_mode": True},
|
||||
"issues": [{"domain": "hassio", "translation_key": "issue_system_reboot_required", "severity": "warning"},
|
||||
@@ -133,7 +134,8 @@ def test_system_problems_are_read_from_what_home_assistant_answers():
|
||||
|
||||
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
|
||||
assert not found["repairs_read"] and not found["supervised"]
|
||||
assert found["disk_free_pct"] is None and found["disk_used_pct"] is None
|
||||
|
||||
|
||||
def test_a_schedule_installs_add_ons_then_core_then_the_os():
|
||||
|
||||
Reference in New Issue
Block a user