feat(homeassistant): waiting updates, backup age, and updates installed after a confirmed backup (#6355)
A new plugin reads Home Assistant with a read-only token every 5 minutes and shows which Core, OS and add-on updates are waiting and how old the last backup is. Install takes a backup through a local-only webhook, confirms Home Assistant's last-backup time moved, installs, and follows the update to its end, through Home Assistant's and Steward's own restarts. One runs at a time. Each ends as one timeline entry. Steward holds nothing that can act on the house: the webhook's automation can only take a backup or install an allowed update (System 231's ruling). A refused token stops polling, as Home Assistant bans an address after a few refusals. Milestone 576 steps 1 to 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{# plugins/homeassistant/templates/homeassistant/_test.html — what Test found. #}
|
||||
{% if found.error %}<p class="tone-crit" style="margin-bottom:0;">{{ found.error }}</p>
|
||||
{% else %}<p class="tone-ok" style="margin-bottom:0.25rem;">Answered: {{ found.entities }} entities, {{ found.updates }} Core, OS and add-on update entities, {{ found.installable }} waiting.</p>
|
||||
<p class="{{ 'field-hint' if found.backup else 'tone-warn' }}" style="margin:0;">{{ "Automatic backups are readable." if found.backup else "No automatic backup sensor: turn on automatic backups in Home Assistant." }}</p>
|
||||
<p class="{{ 'field-hint' if found.automation == 'on' else 'tone-warn' }}" style="margin:0;">{% if found.automation == 'on' %}The Steward maintenance automation is on.{% elif found.automation %}The Steward maintenance automation is {{ found.automation }}.{% else %}The Steward maintenance automation isn't there yet.{% endif %}</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{# plugins/homeassistant/templates/homeassistant/form.html — where Home Assistant
|
||||
is, the read-only token Steward reads it with, and the automation to add so
|
||||
Steward can install updates. A secret is never sent back in a field; a blank
|
||||
one keeps what's saved. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% block title %}Home Assistant connection — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Home Assistant", "/plugins/homeassistant/"), ("Connection", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header("Home Assistant connection") }}
|
||||
|
||||
<div class="form-page">
|
||||
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<form method="post" id="ha-form" action="/plugins/homeassistant/connection">{{ csrf_field() }}
|
||||
<section class="card form-section" aria-labelledby="ha-where">
|
||||
<h2 id="ha-where" class="section-title">Reading it</h2>
|
||||
<div class="form-pair">
|
||||
<div class="field">
|
||||
<label class="field-label" for="ha-url">Address</label>
|
||||
<input class="input mono" id="ha-url" name="url" required maxlength="500" value="{{ v.url or '' }}" placeholder="http://homeassistant.local:8123" autocomplete="off">
|
||||
<span class="field-hint">Its address on your local network, not a public one: the webhook only answers local requests.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="ha-token">Read-only token</label>
|
||||
<input class="input" id="ha-token" name="token" type="password" autocomplete="new-password"
|
||||
placeholder="{{ 'kept; type to change' if v.has_token else 'a long-lived access token' }}">
|
||||
<span class="field-hint">Use a long-lived token of a user in Home Assistant's read-only group, so nothing Steward holds can act on your house.</span>
|
||||
</div>
|
||||
</div>
|
||||
<label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.9rem;">
|
||||
<input type="checkbox" name="verify_tls" value="on"{% if v.verify_tls %} checked{% endif %}> Check its HTTPS certificate
|
||||
</label>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
hx-post="/plugins/homeassistant/connection/test" hx-include="#ha-form" hx-target="#ha-test"
|
||||
hx-indicator="#ha-testing">Test</button>
|
||||
<span id="ha-testing" class="field-hint htmx-indicator">Reading…</span>
|
||||
<span class="field-hint">Reads Home Assistant once with what's typed here. Nothing is saved.</span>
|
||||
</div>
|
||||
<div id="ha-test" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="card form-section" aria-labelledby="ha-install">
|
||||
<h2 id="ha-install" class="section-title">Installing updates</h2>
|
||||
<div class="field">
|
||||
<label class="field-label" for="ha-webhook">Webhook id</label>
|
||||
<input class="input" id="ha-webhook" name="webhook_id" type="password" autocomplete="new-password"
|
||||
placeholder="{{ 'kept; type to change' if v.has_webhook else 'blank makes a new one' }}">
|
||||
<span class="field-hint">Steward makes one when you first save. Type one only if the automation in Home Assistant already has its own.</span>
|
||||
</div>
|
||||
{% if automation %}
|
||||
<p class="field-hint">Add this automation in Home Assistant (Settings → Automations → Create → Edit in YAML). It can take a backup or install a Core, OS or add-on update, and nothing else. It contains the webhook id, so treat it like a password.</p>
|
||||
<pre class="mono" id="ha-automation" style="overflow-x:auto;font-size:0.8rem;">{{ automation }}</pre>
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('ha-automation').textContent).then(()=>this.textContent='Copied')">Copy</button>
|
||||
{% else %}
|
||||
<p class="field-hint" style="margin-bottom:0;">Once saved, this shows the automation to add to Home Assistant.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<a href="/plugins/homeassistant/" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if connection %}
|
||||
<section class="card danger-zone" aria-labelledby="ha-remove">
|
||||
<div>
|
||||
<h2 id="ha-remove" class="section-title">Remove this connection</h2>
|
||||
<p class="field-hint">Steward stops reading Home Assistant and forgets its token. Remove the automation in Home Assistant too.</p>
|
||||
</div>
|
||||
<form method="post" action="/plugins/homeassistant/connection/delete"
|
||||
data-confirm="Remove the Home Assistant connection? Steward stops reading it and forgets its token.">{{ csrf_field() }}
|
||||
<button class="btn btn-danger" type="submit">Remove connection</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
{# plugins/homeassistant/templates/homeassistant/index.html — whether Home
|
||||
Assistant answers, which updates are waiting with Install beside each, how its
|
||||
backups stand, and the updates Steward has run. Refreshes itself while one is
|
||||
under way. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
|
||||
{% block title %}Home Assistant — Steward{% endblock %}
|
||||
{% block content %}
|
||||
{% set admin = session.user_role == 'admin' %}
|
||||
{% call page_header("Home Assistant", connection.url if connection else none) %}
|
||||
{% if admin and connection %}<a class="btn btn-sm btn-ghost" href="/plugins/homeassistant/connection">Connection</a>{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
{% if not connection %}
|
||||
<div class="card">{{ empty_state("Not connected. Connect a Home Assistant instance to see its waiting updates and backups, and to install updates after a confirmed backup.",
|
||||
"Connect" if admin else none, "/plugins/homeassistant/connection") }}</div>
|
||||
{% else %}
|
||||
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<div id="ha-body"{% if under_way %} hx-get="/plugins/homeassistant/" hx-select="#ha-body" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
|
||||
<p class="page-verdict">
|
||||
{% if under_way %}{{ status_badge('pending', 'Updating') }}
|
||||
{{ under_way.title }}: {{ "taking a backup first" if under_way.state == 'backing_up' else "installing " ~ under_way.to_version }}. Home Assistant may stop answering for a while.
|
||||
{% elif reach == 'up' %}{{ status_badge('up', 'Answering') }}
|
||||
{% if installable %}{{ installable|length }} update{{ "s" if installable|length != 1 }} waiting.{% else %}Everything Steward can update is up to date.{% endif %}
|
||||
Read {{ connection.last_polled_at|ago }}.
|
||||
{% elif reach == 'down' %}{{ status_badge('down', 'Not answering') }} <span class="tone-crit">{{ says }}</span>
|
||||
{% if connection.last_ok_at %}Last answered {{ connection.last_ok_at|ago }}; what's below is from then.{% endif %}
|
||||
{% if admin and connection.refused %}<form method="post" action="/plugins/homeassistant/connection/retry" style="display:inline;">{{ csrf_field() }}<button class="btn btn-sm btn-ghost" type="submit">Try again</button></form>{% endif %}
|
||||
{% else %}{{ status_badge('pending', 'Not read yet') }} {{ says }}{% endif %}
|
||||
</p>
|
||||
|
||||
{% if connection.last_ok_at and connection.automation != 'on' %}
|
||||
<p class="tone-warn">{% if connection.automation %}The Steward maintenance automation is {{ connection.automation }} in Home Assistant{% else %}The Steward maintenance automation isn't in Home Assistant{% endif %}, so updates can't be installed from here.
|
||||
{% if admin %}<a href="/plugins/homeassistant/connection">See what to add.</a>{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="stat-strip">
|
||||
{{ 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 "—") }}
|
||||
</div>
|
||||
<p class="{{ 'tone-warn' if backup_tone == 'warn' else 'field-hint' }}">{{ backup_says }}</p>
|
||||
|
||||
<section class="card" aria-labelledby="ha-updates">
|
||||
<h2 id="ha-updates" class="section-title">Updates</h2>
|
||||
{% if installable %}
|
||||
<table class="table">
|
||||
<thead><tr><th scope="col">What</th><th scope="col">Now</th><th scope="col">New</th>{% if admin %}<th scope="col"></th>{% endif %}</tr></thead>
|
||||
<tbody>
|
||||
{% for u in installable %}
|
||||
<tr>
|
||||
<td>{{ u.title }}<div class="cell-sub">{{ u.kind_word }}{% if u.auto_update %}, updates itself{% endif %}</div></td>
|
||||
<td class="mono">{{ u.installed or "—" }}</td>
|
||||
<td class="mono">{% if u.release_url %}<a href="{{ u.release_url }}" rel="noopener noreferrer" target="_blank">{{ u.latest or "—" }}</a>{% else %}{{ u.latest or "—" }}{% endif %}</td>
|
||||
{% if admin %}<td style="text-align:right;">
|
||||
{% if connection.automation == 'on' 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() }}
|
||||
<input type="hidden" name="entity_id" value="{{ u.entity_id }}">
|
||||
<button class="btn btn-sm" type="submit">Install</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="field-hint" style="margin:0;">Nothing waiting.</p>
|
||||
{% endif %}
|
||||
{% if current %}<p class="field-hint">Up to date: {% for u in current %}{{ u.title }} {{ u.installed }}{{ ", " if not loop.last }}{% endfor %}.</p>{% endif %}
|
||||
{% if others %}<p class="field-hint" style="margin-bottom:0;">Waiting, for Home Assistant to install: {% for u in others %}{{ u.title }}{{ ", " if not loop.last }}{% endfor %}. Steward installs Core, OS and add-on updates only.</p>{% endif %}
|
||||
</section>
|
||||
|
||||
{% if runs %}
|
||||
<section class="card" aria-labelledby="ha-runs">
|
||||
<h2 id="ha-runs" class="section-title">Updates run from Steward</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th scope="col">What</th><th scope="col">Versions</th><th scope="col">Outcome</th><th scope="col">Started</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in runs %}
|
||||
<tr>
|
||||
<td>{{ r.title }}<div class="cell-sub">by {{ r.started_by or "Steward" }}</div></td>
|
||||
<td class="mono">{{ r.from_version or "—" }} → {{ r.to_version or "—" }}</td>
|
||||
<td>{% if r.state == 'done' %}{{ status_badge('up', 'Done') }}
|
||||
{% elif r.state == 'failed' %}{{ status_badge('down', 'Failed') }}<div class="cell-sub">{{ r.message }}</div>
|
||||
{% else %}{{ status_badge('pending', 'Taking a backup' if r.state == 'backing_up' else 'Installing') }}{% endif %}</td>
|
||||
<td>{{ r.started_at|ago }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user