feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m58s
CI / publish (push) Successful in 1m13s

A Prometheus target's token_from names the plugin that supplies its token at each
scrape. Read its metrics makes the target; Use this sign-in moves one at that address
across, keeping its name, filters and history. A refusal stops the connection, so one
Try again or sign-in starts everything, and the sign-in is only sent to its own address.
A 404 says how to turn on Home Assistant's Prometheus integration, as Steward doesn't
change Home Assistant. Refused also joins the volatile columns a backup check ignores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 12:06:55 -05:00
co-authored by Claude Opus 5.5
parent 66a5dd12fd
commit 4c9ed02652
19 changed files with 482 additions and 24 deletions
+6
View File
@@ -118,6 +118,12 @@ async def redeem_code(url: str, client_id: str, code: str, verify_tls: bool, tra
return refresh_token, _remember(refresh_token, found)
def forget_access() -> None:
"""Drop access tokens made so far: after a refusal the next ask starts from the
sign-in, which a dead user is refused at without counting toward a ban."""
_access.clear()
async def access_token(connection, transport=None) -> str:
refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in")
token, until = _access.get(refresh_token, (None, 0.0))
+6 -2
View File
@@ -13,6 +13,7 @@ from steward.mcp.tools._shape import age_s, db_session, iso, now_utc, redact_url
from steward.mcp.tools.overview import finding
from steward.models.users import UserRole
from . import metrics
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .routes import connection_says
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
@@ -35,13 +36,16 @@ async def get_home_assistant() -> dict:
`installable` is what update_home_assistant takes (Core, OS, add-ons);
`others_waiting` is firmware and custom integrations, which Steward leaves
to Home Assistant. `can_install` is whether the Home Assistant user who signed
Steward in is an administrator, which installing needs.
Steward in is an administrator, which installing needs. `metrics` is the
Prometheus target that reads its metrics, `uses_sign_in` saying whether that
same user does; null when the Prometheus plugin is off.
"""
now = now_utc()
async with db_session() as db:
c = (await db.execute(select(HomeAssistantConnection))).scalars().first()
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
scrape = await metrics.state(db, c) if c else None
if c is None:
return {"connected": False, "says": "No connection yet: an admin signs in on the Home Assistant page."}
reach, says = connection_says(c, now)
@@ -55,7 +59,7 @@ async def get_home_assistant() -> dict:
"backup": {"verdict": backup_verdict(backup, now)[1], "last_ok": iso(parse_moment(backup.get("last_ok"))),
"age_hours": round(age, 1) if age is not None else None, "stale_after_hours": STALE_BACKUP_HOURS,
"next": iso(parse_moment(backup.get("next"))), "last_result": backup.get("last_result")},
"updates": [_run_view(r) for r in runs]}
"metrics": scrape, "updates": [_run_view(r) for r in runs]}
async def update_home_assistant(entity_id: str) -> dict:
+153
View File
@@ -0,0 +1,153 @@
# plugins/homeassistant/metrics.py
"""Home Assistant's metrics, read with the one sign-in Steward already holds, so a
single Home Assistant user covers everything. The scrape stays a Prometheus target:
this lends it a token at each scrape, sends that token only to this Home Assistant,
and stops it when the sign-in has been refused."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import or_, select, update
from steward.core.plugin_manager import loaded_plugin_submodule
from steward.core.prometheus import TokenUnavailable
from . import client
from .models import HomeAssistantConnection
from .service import SOURCE
if TYPE_CHECKING:
from quart import Quart
METRICS_PATH = "/api/prometheus"
NAME = "HomeAssistant"
INTERVAL_SECONDS = 120
TRY_SECONDS = 30
# What a new target leaves out: bookkeeping on every entity, a few thousand series
# that say nothing the readings don't.
DROP = ["homeassistant_state_change*", "homeassistant_last_updated_time_seconds", "homeassistant_entity_available",
"homeassistant_entity_info", "homeassistant_area_info", "python_*", "process_*"]
REFUSED = "Home Assistant refused Steward's sign-in while reading its metrics, or has banned this address: look in its ip_bans.yaml."
class CannotRead(Exception):
"""Why the metrics can't be read with this sign-in, said to the person."""
def metrics_url(connection) -> str:
return connection.url.rstrip("/") + METRICS_PATH
# ── Lending the sign-in to a scrape ──────────────────────────────────────────
async def scrape_token(app: "Quart", url: str) -> str:
"""A token for scraping `url`. Raises TokenUnavailable, asking nothing of
anyone, once the sign-in was refused: another refused request counts toward a
ban. A target edited to point elsewhere isn't sent this user's token."""
async with app.db_sessionmaker() as db:
connection = (await db.execute(select(HomeAssistantConnection))).scalars().first()
if connection is None:
raise TokenUnavailable("Home Assistant isn't connected: sign in on its page to read its metrics.")
if connection.refused:
raise TokenUnavailable(f"{connection.last_error} Steward has stopped asking until you sign in again "
"or choose Try again on the Home Assistant page.")
if url != metrics_url(connection):
raise TokenUnavailable(f"This isn't the address of the Home Assistant Steward signed in to, so its sign-in "
f"isn't sent here. Set the address to {metrics_url(connection)}, or type a token.")
try:
return await client.access_token(connection)
except (client.Refused, client.Unreachable) as exc:
raise TokenUnavailable(str(exc)) from None
async def token_refused(app: "Quart") -> None:
"""A scrape's token was refused: stop everything using this sign-in."""
client.forget_access()
async with app.db_sessionmaker() as db:
async with db.begin():
connection = (await db.execute(select(HomeAssistantConnection))).scalars().first()
if connection is not None:
connection.refused, connection.last_error = True, REFUSED
# ── The Metrics section of the page ──────────────────────────────────────────
def _prometheus(sub: str):
return loaded_plugin_submodule("prometheus", sub)
async def _target(db, connection):
"""The target using this sign-in, or else the one at this address."""
T = _prometheus("models").PrometheusTarget
rows = (await db.execute(select(T).where(or_(T.token_from == SOURCE, T.url == metrics_url(connection))))).scalars().all()
return next((t for t in rows if t.token_from == SOURCE), rows[0] if rows else None)
async def state(db, connection) -> dict | None:
"""What the page says about the metrics. None when the Prometheus plugin is off;
`id` is None when no target reads them yet."""
service = _prometheus("service")
if service is None or _prometheus("models") is None:
return None
target = await _target(db, connection)
if target is None:
return {"id": None, "name": None, "uses_sign_in": False, "reach": None, "says": None, "kept": 0}
found = service.target_state(target, datetime.now(timezone.utc))
return {"id": target.id, "name": target.name, "uses_sign_in": target.token_from == SOURCE,
"reach": found.reach, "says": found.says, "kept": target.last_kept}
async def try_reading(app: "Quart", connection) -> None:
"""Ask for the metrics once, so turning this on says at once if they aren't there."""
scraper = _prometheus("scraper")
if scraper is None:
raise CannotRead("the Prometheus plugin isn't turned on.")
if connection.refused:
raise CannotRead("Home Assistant has refused this sign-in. Choose Try again or sign in again first.")
try:
token = await client.access_token(connection)
await asyncio.wait_for(scraper.scrape(metrics_url(connection), token, connection.verify_tls), TRY_SECONDS)
except scraper.NoMetricsHere:
raise CannotRead("Home Assistant isn't serving metrics. Add prometheus: to its configuration.yaml, restart it, "
"and come back: Steward doesn't change Home Assistant.") from None
except scraper.TokenRefused:
await token_refused(app)
raise CannotRead(REFUSED) from None
except TimeoutError:
raise CannotRead(f"Home Assistant's metrics didn't arrive within {TRY_SECONDS} s.") from None
except (scraper.ScrapeError, client.Refused, client.Unreachable) as exc:
raise CannotRead(str(exc)) from None
async def use_sign_in(app: "Quart", connection) -> tuple[str, str, bool]:
"""Make the target, or move the one at this address onto this sign-in, keeping
its name, filters and history. Returns (id, name, whether it was made)."""
models, scheduler = _prometheus("models"), _prometheus("scheduler")
T = models.PrometheusTarget
async with app.db_sessionmaker() as db:
async with db.begin():
target = await _target(db, connection)
created = target is None
if created:
if (await db.execute(select(T.id).where(T.name == NAME))).first():
raise CannotRead(f"another Prometheus target is already named {NAME}. Rename it first.")
target = T(name=NAME, interval_seconds=INTERVAL_SECONDS, drop=list(DROP))
db.add(target)
target.url, target.verify_tls = metrics_url(connection), connection.verify_tls
target.token, target.token_from, target.refused = "", SOURCE, False
target.last_scraped_at = target.last_error = None
await db.flush()
target_id, name = target.id, target.name
scheduler.RATES.forget(target_id)
return target_id, name, created
async def follow_sign_in(db, connection) -> None:
"""Signing in at a new address takes the scrape using this sign-in with it."""
models = _prometheus("models")
if models is not None:
T = models.PrometheusTarget
await db.execute(update(T).where(T.token_from == SOURCE).values(
url=metrics_url(connection), verify_tls=connection.verify_tls))
+27 -3
View File
@@ -18,7 +18,7 @@ from steward.core.crypto import encrypt_secret
from steward.core.settings import public_base_url
from steward.models.users import UserRole
from . import client
from . import client, metrics
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
from .updates import CannotStart, start_update
@@ -55,6 +55,7 @@ async def _render_index(error: str | None = None, status: int = 200):
connection = await _connection(db)
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
scrape = await metrics.state(db, connection) if connection else None
if connection is None:
return await render_template("homeassistant/index.html", connection=None, error=error), status
now = datetime.now(timezone.utc)
@@ -63,7 +64,7 @@ async def _render_index(error: str | None = None, status: int = 200):
backup = connection.backup or {}
tone, backup_says = backup_verdict(backup, now)
return await render_template(
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says,
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says, metrics=scrape,
installable=installable(connection),
others=[u for u in updates if u["waiting"] and not u["kind"]],
current=[u for u in updates if not u["waiting"] and u["kind"]],
@@ -95,6 +96,26 @@ async def install():
return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/metrics")
@require_role(UserRole.admin)
async def connect_metrics():
"""Read Home Assistant's metrics with this sign-in, after asking for them once
to show they're there. An admin's explicit action, so it may ask."""
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
if connection is None:
return redirect("/plugins/homeassistant/")
try:
await metrics.try_reading(current_app, connection)
target_id, name, created = await metrics.use_sign_in(current_app, connection)
except metrics.CannotRead as exc:
return await _render_index(f"Nothing changed: {exc}", 409)
await log_audit(current_app, *_actor(), "prometheus.target_created" if created else "prometheus.target_updated",
entity_type="prometheus_target", entity_id=target_id,
detail={"name": name, "token_from": metrics.SOURCE})
return redirect("/plugins/homeassistant/")
# ── The connection ───────────────────────────────────────────────────────────
async def _render_form(connection, url: str | None = None, verify_tls: bool | None = None,
@@ -167,6 +188,7 @@ async def sign_in_callback():
connection.can_install, connection.signed_in_by = can_install, username
connection.signed_in_at = datetime.now(timezone.utc)
connection.refused, connection.last_error, connection.last_polled_at = False, None, None
await metrics.follow_sign_in(db, connection)
await db.flush()
connection_id = connection.id
if previous is not None:
@@ -192,7 +214,9 @@ async def _end_sign_in(connection) -> bool:
@homeassistant_bp.post("/connection/retry")
@require_role(UserRole.admin)
async def retry_connection():
"""Ask again after a refusal, once a ban has been dealt with."""
"""Ask again after a refusal, once a ban has been dealt with. A metrics scrape
using this sign-in starts again with it."""
client.forget_access()
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)
@@ -23,7 +23,7 @@
<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 verify_tls %} checked{% endif %}> Check its HTTPS certificate
</label>
<p class="tone-warn"><strong>Steward stores a credential with the same access as the user who signs in.</strong> Home Assistant has no narrower permission, so an administrator's credential can do anything that administrator can, including controlling every device. Steward only uses it to read status, take backups and install updates.</p>
<p class="tone-warn"><strong>Steward stores a credential with the same access as the user who signs in.</strong> Home Assistant has no narrower permission, so an administrator's credential can do anything that administrator can, including controlling every device. Steward only uses it to read status and metrics, take backups and install updates.</p>
<p class="field-hint"><strong>We recommend a service account:</strong> a Home Assistant user made just for Steward (Settings → People → Add person). What Steward does then appears under its own name in Home Assistant's logs, so it's easy to tell apart from what people do.</p>
<p class="field-hint" style="margin-bottom:0;">Installing updates needs an administrator. With any other user, Steward shows status only.</p>
</section>
@@ -38,7 +38,7 @@
<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, forgets its credential and ends the sign-in there.</p>
<p class="field-hint">Steward stops reading Home Assistant, its metrics included, forgets its credential and ends the sign-in there.</p>
</div>
<form method="post" action="/plugins/homeassistant/connection/delete"
data-confirm="Remove the Home Assistant connection? Steward stops reading it and forgets its credential.">{{ csrf_field() }}
@@ -73,6 +73,22 @@
{% 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 metrics %}
<section class="card" aria-labelledby="ha-metrics">
<h2 id="ha-metrics" class="section-title">Metrics</h2>
{% if metrics.uses_sign_in %}
<p class="field-hint" style="margin-bottom:0;">{{ status_badge(metrics.reach) }} Read with this sign-in into the target <a href="/plugins/prometheus/targets/{{ metrics.id }}">{{ metrics.name }}</a>: {{ metrics.says }}{% if metrics.reach == 'up' %} {{ metrics.kept }} series kept.{% endif %}</p>
{% elif metrics.id %}
<p class="field-hint">The target <a href="/plugins/prometheus/targets/{{ metrics.id }}">{{ metrics.name }}</a> reads these with a token of its own: {{ metrics.says }}</p>
{% if admin %}<p class="field-hint">Use this sign-in instead, so one Home Assistant user covers everything. The target keeps its name, filters and history, and forgets its token.</p>
<form method="post" action="/plugins/homeassistant/metrics">{{ csrf_field() }}<button class="btn btn-sm" type="submit">Use this sign-in</button></form>{% endif %}
{% else %}
<p class="field-hint">Home Assistant's entity readings can be kept as metrics too, for charts and alert rules, with this sign-in and nothing else to set up.</p>
{% if admin %}<form method="post" action="/plugins/homeassistant/metrics">{{ csrf_field() }}<button class="btn btn-sm" type="submit">Read its metrics</button></form>{% endif %}
{% endif %}
</section>
{% endif %}
{% if runs %}
<section class="card" aria-labelledby="ha-runs">
<h2 id="ha-runs" class="section-title">Updates run from Steward</h2>