Files
FabledSteward/plugins/homeassistant/metrics.py
T
bvandeusenandClaude Opus 5.5 4c9ed02652
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m58s
CI / publish (push) Successful in 1m13s
feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
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>
2026-11-01 12:06:55 -05:00

154 lines
7.3 KiB
Python

# 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))