-
Notifications
You must be signed in to change notification settings - Fork 1
Per-Panel SPAN HOP Dashboards in Home Assistant #18
This is a small recipe that uses the HOP Dashboard auto-login query parameter to embed each SPAN panel's local dashboard, already authenticated, as its own panel-mode dashboard in Home Assistant. The result: one sidebar entry per panel, click it, the panel's UI fills the screen — no manual login.
What you get
For each SPAN panel you have, a Home Assistant dashboard whose only view is panel: true (full-screen) containing a single iframe pointed at http://<panel-ip>/login?passphrase=<hopPassphrase>. Per the auto-login docs, the panel consumes the passphrase query parameter, redirects to /home already authenticated, and strips the passphrase from the URL bar before any history is recorded.
Above: one of the resulting dashboards. The HA sidebar shows three SPAN entries (renamed here from the script's defaults to "SPAN LC1 / LC2 / LC3"), and the selected one fills the viewport with the panel's own HOP UI — Home page, 2.6 kW total, source breakdown — all live and already authenticated. The top-left page header shows the panel's serial (redacted as <panel-serial> here); HA's standard sidebar with your other dashboards stays available on the left.
Prerequisites — and a constraint worth noting up front
This recipe assumes you've set up SPAN in Home Assistant via the electrification-bus/span-hass custom integration (the eBus variant). That integration's stored config-entry data includes an ebus_broker_password field whose value is the same string as the panel's hopPassphrase. I verified this by POSTing the broker password as hopPassphrase to /api/v1/auth/register on the panel and seeing it returned verbatim in the response along with a fresh access token — the broker password and the dashboard passphrase are the same secret on this generation of firmware.
The other SPAN integration most people use — SpanPanel/span — does not store the hopPassphrase anywhere accessible. Its config-entry has only an access_token (a JWT issued by the panel at setup time), which the dashboard's /login page does not accept as a passphrase. If that's the integration you have, you'll need to either: provide the passphrase out-of-band (an !secret reference in YAML, environment variable, hardcoded in the script), or skip the script and just paste the URLs into dashboards by hand.
You also need: SSH access to the HA host as root with key auth (used to read the config-entries storage file), a long-lived HA access token (used for the WebSocket API), and the websockets Python module locally.
The script
It discovers every span_ebus config entry, builds an auto-login URL from each, and creates or refreshes a panel-mode dashboard per panel. No secrets in the source — passphrases are read from the HAY at runtime.
#!/usr/bin/env python3 """Build/refresh per-panel SPAN dashboards on the HA Yellow. Creates one panel-mode dashboard per SPAN panel configured in the span_ebus integration. Each dashboard is a single iframe that opens the SPAN HOP local dashboard with auto-login via the `?passphrase=<...>` query param documented at https://github.com/SpanPanel/SPAN-API-Client-Docs/blob/main/docs/public/hop-dashboard-auto-login.md The hopPassphrase for each panel is read from the HAY's /config/.storage/core.config_entries (the integration stores it under `ebus_broker_password`, which the panel accepts as the dashboard passphrase — verified by POSTing to /api/v1/auth/register and seeing it echoed back as `hopPassphrase`). No secrets are baked into this script. Requirements: - SSH access to the HAY as root@<your-ha-host> (used to read core.config_entries; key auth assumed) - HASS_API_TOKEN env var (long-lived access token, used for the WebSocket API) - websockets pip module - Assumes the electrification-bus/span-hass integration (domain `span_ebus`), NOT the SpanPanel/span integration. """ import asyncio import json import os import subprocess import websockets HAY_SSH = "root@your-ha-host" # edit for your setup WS_URL = "ws://your-ha-host:8123/api/websocket" def short_id(serial: str) -> str: """Panel serial like 'nt-2143-c1akc' -> short id '2143' for use in url_path.""" parts = serial.split("-") return parts[1] if len(parts) >= 2 else serial def fetch_span_panels(): out = subprocess.run( ["ssh", HAY_SSH, "cat /config/.storage/core.config_entries"], check=True, capture_output=True, text=True, ) cfg = json.loads(out.stdout) return [e for e in cfg["data"]["entries"] if e["domain"] == "span_ebus"] def dashboard_spec(panel: dict) -> dict: serial = panel["data"]["serial_number"] sid = short_id(serial) host = panel["data"]["host"] passphrase = panel["data"]["ebus_broker_password"] login_url = f"http://{host}/login?passphrase={passphrase}" return { "url_path": f"dashboard-span-{sid}", "title": f"SPAN {serial.rsplit('-', 1)[0]}", "icon": "mdi:transmission-tower", "config": { "title": f"SPAN {serial.rsplit('-', 1)[0]}", "views": [{ "path": "default", "title": serial, "icon": "mdi:transmission-tower", "panel": True, "cards": [{ "type": "iframe", "url": login_url, "aspect_ratio": "75%", }], }], }, } async def call(ws, mid, payload): await ws.send(json.dumps({"id": mid, **payload})) return json.loads(await ws.recv()) async def main(): panels = fetch_span_panels() print(f"found {len(panels)} span_ebus panels") specs = [dashboard_spec(p) for p in panels] token = os.environ["HASS_API_TOKEN"] async with websockets.connect(WS_URL, max_size=16 * 1024 * 1024) as ws: assert json.loads(await ws.recv())["type"] == "auth_required" await ws.send(json.dumps({"type": "auth", "access_token": token})) assert json.loads(await ws.recv())["type"] == "auth_ok" r = await call(ws, 1, {"type": "lovelace/dashboards/list"}) existing = {d["url_path"] for d in r["result"]} mid = 2 for spec in specs: if spec["url_path"] not in existing: r = await call(ws, mid, { "type": "lovelace/dashboards/create", "url_path": spec["url_path"], "require_admin": False, "show_in_sidebar": True, "icon": spec["icon"], "title": spec["title"], "mode": "storage", }) mid += 1 print(f" create {spec['url_path']}: success={r.get('success')}") r = await call(ws, mid, { "type": "lovelace/config/save", "url_path": spec["url_path"], "config": spec["config"], }) mid += 1 print(f" save {spec['url_path']}: success={r.get('success')}") if __name__ == "__main__": asyncio.run(main())
Running it
Edit the HAY_SSH and WS_URL constants for your environment, then:
export HASS_API_TOKEN='your-long-lived-token' python build-span-dashboards.py
You should see one create + save (or just save on re-runs) per panel. After that, open Home Assistant and you'll find one new sidebar entry per panel, each opening directly into the panel's HOP UI in panel mode.
Things worth knowing
Iframe-ability. The SPAN HOP dashboard nginx sends no X-Frame-Options header and no Content-Security-Policy with frame-ancestors, so it embeds cleanly. If your HA instance is served over HTTPS but the panel only over plain HTTP, expect a mixed-content block in the browser; on a LAN-internal HA accessed over HTTP this isn't an issue. You can also adapt the script to use HTTPS to the panel (the cert is self-signed, so browsers may need a one-time accept).
Where the secret ends up. The script writes a URL containing the panel's hopPassphrase into HA's .storage/lovelace.dashboard_span_<id> file. The same secret already lives in HA's .storage/core.config_entries (the integration's ebus_broker_password), so this is the same trust boundary, not a fresh exposure. But if you commit your HA config to git, you'll want the dashboard storage files in .gitignore (or commit only the script and have the dashboard files regenerated on each machine).
Re-run safety. The script is idempotent: it creates dashboards that don't exist yet and overwrites the config on ones that do. Re-run any time a panel's IP or passphrase changes — it'll pull the new values from the integration's config-entry and refresh.
Sidebar ordering. Three new entries will show up at the bottom of the sidebar. To reorder, long-press the "Home Assistant" text at the top of the sidebar to drop into edit mode, drag, and click DONE.
Why this works
Per the HOP Dashboard auto-login docs, the dashboard's /login page accepts a passphrase query parameter. When present, it auto-submits the standard POST /api/v1/auth/register flow with hopPassphrase set to the query value, then strips the parameter from the URL via history.replaceState. An invalid passphrase falls back to the manual form with an error displayed; an empty value is ignored. So the embed Just Works as long as the passphrase we send matches what the panel was provisioned with — which on the span_ebus integration is the value already stored in ebus_broker_password.
All reactions
Replies: 1 comment
Note that the link/URL to HOP Dashboard auto-login docs is not currently reachable, but will be "soon"