franklinwh-system-view 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. franklinwh_system_view-0.1.0/LICENSE +21 -0
  2. franklinwh_system_view-0.1.0/PKG-INFO +57 -0
  3. franklinwh_system_view-0.1.0/README.md +43 -0
  4. franklinwh_system_view-0.1.0/pyproject.toml +28 -0
  5. franklinwh_system_view-0.1.0/setup.cfg +4 -0
  6. franklinwh_system_view-0.1.0/src/franklinwh_system_view/__init__.py +40 -0
  7. franklinwh_system_view-0.1.0/src/franklinwh_system_view/demo.py +69 -0
  8. franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/au_single_phase.json +7 -0
  9. franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/offline.json +4 -0
  10. franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/six_apowers.json +10 -0
  11. franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/us_backup_no_solar.json +8 -0
  12. franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/us_outage.json +7 -0
  13. franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/us_tou_two_apowers_solar.json +8 -0
  14. franklinwh_system_view-0.1.0/src/franklinwh_system_view/layout.py +279 -0
  15. franklinwh_system_view-0.1.0/src/franklinwh_system_view/schema.py +79 -0
  16. franklinwh_system_view-0.1.0/src/franklinwh_system_view/static/system-view.css +82 -0
  17. franklinwh_system_view-0.1.0/src/franklinwh_system_view/static/system-view.js +243 -0
  18. franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/PKG-INFO +57 -0
  19. franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/SOURCES.txt +23 -0
  20. franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/dependency_links.txt +1 -0
  21. franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/requires.txt +3 -0
  22. franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/top_level.txt +1 -0
  23. franklinwh_system_view-0.1.0/tests/test_layout.py +113 -0
  24. franklinwh_system_view-0.1.0/tests/test_page.py +162 -0
  25. franklinwh_system_view-0.1.0/tests/test_schema.py +40 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 david2069
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: franklinwh-system-view
3
+ Version: 0.1.0
4
+ Summary: A friendly picture of a FranklinWH site (grid, aGate, batteries, solar, home, circuits) shared by the FranklinWH bridges: payload schema, layout and the overlay page
5
+ Author: david2069
6
+ License: MIT
7
+ Project-URL: Source, https://github.com/david2069/franklinwh-system-view
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=8.0; extra == "test"
13
+ Dynamic: license-file
14
+
15
+ # franklinwh-system-view
16
+
17
+ A friendly picture of a FranklinWH site, shared by the FranklinWH bridges: grid, aGate,
18
+ batteries, solar, home, smart circuits and generator, with live flows. It opens as a
19
+ full-page overlay over a bridge's own dashboard, and can be the page a browser starts on.
20
+
21
+ - **Simple by default.** Pictures and plain words: battery charge, mode, what the inverter
22
+ is doing, grid up or down, solar, home use, circuits. Hover for a sentence, click for details.
23
+ - **Fits any screen.** Desktop and tablet get a landscape layout, phones a portrait one.
24
+ Zoom with − / Fit / +, pinch, or Ctrl+wheel; drag to pan when zoomed.
25
+ - **Colours mean state.** Green normal, grey offline or disabled, amber warning, red critical.
26
+ - **Only what's there is drawn.** A site with no solar shows no solar.
27
+
28
+ ## How a bridge uses it
29
+
30
+ ```python
31
+ from fastapi.staticfiles import StaticFiles
32
+ import franklinwh_system_view as sv
33
+
34
+ app.mount("/system-view", StaticFiles(directory=sv.static_dir()), name="system-view")
35
+
36
+ @app.get("/api/system")
37
+ def api_system():
38
+ return sv.build(my_payload()) # validates, then adds both layouts per gateway
39
+ ```
40
+
41
+ ```html
42
+ <link rel="stylesheet" href="system-view/system-view.css">
43
+ <script src="system-view/system-view.js"></script>
44
+ <script>FWHSystemView.mount({endpoint: "api/system"});</script>
45
+ <button onclick="FWHSystemView.open()">System view</button>
46
+ ```
47
+
48
+ The payload is described in `schema.py`; `fixtures/` holds examples. Signs: grid
49
+ `power_w` > 0 imports, battery `power_w` > 0 discharges. Leave out what you can't read.
50
+
51
+ ## Try it
52
+
53
+ ```bash
54
+ python -m franklinwh_system_view.demo us_tou_two_apowers_solar
55
+ ```
56
+
57
+ Design: franklinwh-direct-connect-bridge #428.
@@ -0,0 +1,43 @@
1
+ # franklinwh-system-view
2
+
3
+ A friendly picture of a FranklinWH site, shared by the FranklinWH bridges: grid, aGate,
4
+ batteries, solar, home, smart circuits and generator, with live flows. It opens as a
5
+ full-page overlay over a bridge's own dashboard, and can be the page a browser starts on.
6
+
7
+ - **Simple by default.** Pictures and plain words: battery charge, mode, what the inverter
8
+ is doing, grid up or down, solar, home use, circuits. Hover for a sentence, click for details.
9
+ - **Fits any screen.** Desktop and tablet get a landscape layout, phones a portrait one.
10
+ Zoom with − / Fit / +, pinch, or Ctrl+wheel; drag to pan when zoomed.
11
+ - **Colours mean state.** Green normal, grey offline or disabled, amber warning, red critical.
12
+ - **Only what's there is drawn.** A site with no solar shows no solar.
13
+
14
+ ## How a bridge uses it
15
+
16
+ ```python
17
+ from fastapi.staticfiles import StaticFiles
18
+ import franklinwh_system_view as sv
19
+
20
+ app.mount("/system-view", StaticFiles(directory=sv.static_dir()), name="system-view")
21
+
22
+ @app.get("/api/system")
23
+ def api_system():
24
+ return sv.build(my_payload()) # validates, then adds both layouts per gateway
25
+ ```
26
+
27
+ ```html
28
+ <link rel="stylesheet" href="system-view/system-view.css">
29
+ <script src="system-view/system-view.js"></script>
30
+ <script>FWHSystemView.mount({endpoint: "api/system"});</script>
31
+ <button onclick="FWHSystemView.open()">System view</button>
32
+ ```
33
+
34
+ The payload is described in `schema.py`; `fixtures/` holds examples. Signs: grid
35
+ `power_w` > 0 imports, battery `power_w` > 0 discharges. Leave out what you can't read.
36
+
37
+ ## Try it
38
+
39
+ ```bash
40
+ python -m franklinwh_system_view.demo us_tou_two_apowers_solar
41
+ ```
42
+
43
+ Design: franklinwh-direct-connect-bridge #428.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "franklinwh-system-view"
7
+ version = "0.1.0"
8
+ description = "A friendly picture of a FranklinWH site (grid, aGate, batteries, solar, home, circuits) shared by the FranklinWH bridges: payload schema, layout and the overlay page"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "david2069"}]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ test = ["pytest>=8.0"]
17
+
18
+ [project.urls]
19
+ Source = "https://github.com/david2069/franklinwh-system-view"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.setuptools.package-data]
25
+ franklinwh_system_view = ["static/*", "fixtures/*.json"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ """The FranklinWH System view: one picture of a site's hardware, shared by both bridges.
2
+
3
+ A bridge builds a payload (``schema``), the layout turns each gateway into a scene, and the
4
+ page in ``static/`` draws it as an overlay over the bridge's own UI.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ from .layout import scene
12
+ from .schema import SCHEMA_VERSION, validate
13
+
14
+ __version__ = "0.1.0"
15
+ __all__ = ["SCHEMA_VERSION", "build", "fixture", "scene", "static_dir", "validate"]
16
+
17
+
18
+ def static_dir() -> Path:
19
+ """The folder a bridge serves at ``/system-view/`` (system-view.js and .css)."""
20
+ return Path(str(resources.files(__package__) / "static"))
21
+
22
+
23
+ def build(payload: dict) -> dict:
24
+ """``payload`` plus both scenes for every gateway, ready to send to the page.
25
+
26
+ Raises ``ValueError`` listing every problem when the payload is invalid.
27
+ """
28
+ errs = validate(payload)
29
+ if errs:
30
+ raise ValueError("; ".join(errs))
31
+ out = dict(payload)
32
+ out["gateways"] = [dict(g, scenes={o: scene(g, o) for o in ("landscape", "portrait")})
33
+ for g in payload["gateways"]]
34
+ return out
35
+
36
+
37
+ def fixture(name: str) -> dict:
38
+ """An example payload shipped with the package (see ``fixtures/``), for tests and demos."""
39
+ import json
40
+ return json.loads((resources.files(__package__) / "fixtures" / f"{name}.json").read_text())
@@ -0,0 +1,69 @@
1
+ """See the System view without a bridge: ``python -m franklinwh_system_view.demo [fixture] [port]``.
2
+
3
+ Serves a stand-in "standard dashboard" with a System view button, the overlay's files,
4
+ and ``/api/system`` built from one of the shipped example payloads.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ from . import build, fixture, static_dir
14
+
15
+ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1">
17
+ <title>System view demo</title><link rel="stylesheet" href="/system-view/system-view.css">
18
+ <style>body{font-family:system-ui,sans-serif;margin:0;padding:16px;background:#121924;color:#e4e9f0}</style></head>
19
+ <body><header style="display:flex;gap:12px;align-items:center"><h1 style="margin:0;font-size:1.2rem">Standard dashboard</h1>
20
+ <button id="open" type="button">System view</button></header>
21
+ <p>This stands in for a bridge's own dashboard.</p>
22
+ <script src="/system-view/system-view.js"></script>
23
+ <script>FWHSystemView.mount({endpoint: "/api/system" + location.search});
24
+ document.getElementById("open").onclick = () => FWHSystemView.open();</script></body></html>"""
25
+
26
+
27
+ def make_handler(default: str):
28
+ class H(BaseHTTPRequestHandler):
29
+ def log_message(self, *a): # quiet
30
+ pass
31
+
32
+ def _send(self, code, body, ctype):
33
+ data = body if isinstance(body, bytes) else body.encode()
34
+ self.send_response(code)
35
+ self.send_header("Content-Type", ctype)
36
+ self.send_header("Content-Length", str(len(data)))
37
+ self.end_headers()
38
+ self.wfile.write(data)
39
+
40
+ def do_GET(self):
41
+ u = urlparse(self.path)
42
+ if u.path == "/":
43
+ return self._send(200, PAGE, "text/html; charset=utf-8")
44
+ if u.path == "/favicon.ico":
45
+ return self._send(204, b"", "image/x-icon")
46
+ if u.path == "/api/system":
47
+ name = parse_qs(u.query).get("fixture", [default])[0]
48
+ try:
49
+ return self._send(200, json.dumps(build(fixture(name))), "application/json")
50
+ except FileNotFoundError:
51
+ return self._send(404, json.dumps({"detail": f"no fixture {name!r}"}), "application/json")
52
+ if u.path.startswith("/system-view/"):
53
+ f = static_dir() / u.path.rsplit("/", 1)[-1]
54
+ if f.is_file():
55
+ ctype = "text/css" if f.suffix == ".css" else "text/javascript"
56
+ return self._send(200, f.read_bytes(), ctype)
57
+ return self._send(404, "not found", "text/plain")
58
+ return H
59
+
60
+
61
+ def serve(default: str = "us_backup_no_solar", port: int = 8765) -> ThreadingHTTPServer:
62
+ return ThreadingHTTPServer(("127.0.0.1", port), make_handler(default))
63
+
64
+
65
+ if __name__ == "__main__":
66
+ name = sys.argv[1] if len(sys.argv) > 1 else "us_backup_no_solar"
67
+ port = int(sys.argv[2]) if len(sys.argv) > 2 else 8765
68
+ print(f"http://127.0.0.1:{port}/ (fixture {name}; add ?fixture=<name> to switch)")
69
+ serve(name, port).serve_forever()
@@ -0,0 +1,7 @@
1
+ {"version": 1, "site": {"name": "Example home", "timezone": "Australia/Sydney"},
2
+ "gateways": [{"id": "gw1", "name": "aGate", "kind": "agate", "online": true,
3
+ "mode": "Self-Consumption", "inverter": "Discharging", "reserve_pct": 20,
4
+ "grid": {"state": "connected", "power_w": -300, "voltage_v": 242.9, "freq_hz": 50.01, "legs_v": [242.9]},
5
+ "home": {"power_w": 900}, "battery": {"soc_pct": 74, "power_w": 400, "count": 1},
6
+ "solar": [{"id": "solar", "where": "agate", "power_w": 800}],
7
+ "circuits": [{"id": 1, "name": "Circuit 1", "on": true}, {"id": 2, "name": "Circuit 2", "on": false}]}]}
@@ -0,0 +1,4 @@
1
+ {"version": 1, "site": {"name": "Example home"},
2
+ "gateways": [{"id": "gw1", "name": "aGate", "kind": "agate", "online": false,
3
+ "mode": null, "inverter": null, "grid": {"state": "unknown", "power_w": null},
4
+ "home": {"power_w": null}, "battery": {"soc_pct": null, "power_w": null}, "solar": [], "circuits": []}]}
@@ -0,0 +1,10 @@
1
+ {"version": 1, "site": {"name": "Example home"},
2
+ "gateways": [{"id": "gw1", "name": "aGate", "kind": "agate", "online": true,
3
+ "mode": "Self-Consumption", "inverter": "Discharging",
4
+ "grid": {"state": "connected", "power_w": 120}, "home": {"power_w": 6100},
5
+ "battery": {"soc_pct": 71, "power_w": 6000, "count": 6, "units": [
6
+ {"id": "u1", "soc_pct": 72, "power_w": 1000}, {"id": "u2", "soc_pct": 70, "power_w": 1000},
7
+ {"id": "u3", "soc_pct": 71, "power_w": 1000, "state": "warn"}, {"id": "u4", "soc_pct": 71, "power_w": 1000},
8
+ {"id": "u5", "soc_pct": 70, "power_w": 1000}, {"id": "u6", "soc_pct": 72, "power_w": 1000}]},
9
+ "solar": [{"id": "pv1", "where": "agate", "power_w": 2000}, {"id": "pv2", "where": "apbox", "power_w": 1500}],
10
+ "circuits": []}]}
@@ -0,0 +1,8 @@
1
+ {"version": 1, "site": {"name": "Example home", "timezone": "America/Los_Angeles"},
2
+ "gateways": [{"id": "gw1", "name": "aGate", "kind": "agate", "online": true,
3
+ "mode": "Emergency Backup", "inverter": "Standby", "reserve_pct": 100,
4
+ "grid": {"state": "connected", "power_w": 820, "voltage_v": 241.6, "freq_hz": 60.0, "legs_v": [120.8, 120.8]},
5
+ "home": {"power_w": 820},
6
+ "battery": {"soc_pct": 100, "power_w": 0, "count": 1},
7
+ "solar": [],
8
+ "circuits": [{"id": 1, "name": "Circuit 1", "on": true}, {"id": 2, "name": "Circuit 2", "on": true}, {"id": 3, "name": "Circuit 3", "on": false}]}]}
@@ -0,0 +1,7 @@
1
+ {"version": 1, "site": {"name": "Example home", "timezone": "America/Denver"},
2
+ "gateways": [{"id": "gw1", "name": "aGate", "kind": "agate", "online": true,
3
+ "mode": "Emergency Backup", "inverter": "Off-grid", "reserve_pct": 100,
4
+ "grid": {"state": "outage", "power_w": 0, "voltage_v": null, "freq_hz": null},
5
+ "home": {"power_w": 1900},
6
+ "battery": {"soc_pct": 86, "power_w": 1900, "count": 1},
7
+ "solar": [], "circuits": [], "generator": {"power_w": 0}}]}
@@ -0,0 +1,8 @@
1
+ {"version": 1, "site": {"name": "Example home", "timezone": "America/Los_Angeles"},
2
+ "gateways": [{"id": "gw1", "name": "aGate", "kind": "agate", "online": true,
3
+ "mode": "Time-of-Use", "inverter": "Charging", "reserve_pct": 20,
4
+ "grid": {"state": "connected", "power_w": 0, "voltage_v": 240.2, "freq_hz": 60.0, "legs_v": [120.1, 120.1]},
5
+ "home": {"power_w": 1300},
6
+ "battery": {"soc_pct": 62, "power_w": -2900, "count": 2},
7
+ "solar": [{"id": "solar", "where": "agate", "power_w": 4200}],
8
+ "circuits": [{"id": 1, "name": "Circuit 1", "on": true}, {"id": 2, "name": "Circuit 2", "on": true}, {"id": 3, "name": "Circuit 3", "on": true}]}]}
@@ -0,0 +1,279 @@
1
+ """Turn one gateway's payload into a scene: positioned pictures and the wires between them.
2
+
3
+ The layout is computed here, in Python, so both bridges draw the same picture and the
4
+ arrangement is tested without a browser. The page only renders the scene.
5
+
6
+ Two arrangements: ``landscape`` (desktop and tablet) and ``portrait`` (phones). Only what
7
+ the payload reports is drawn; a gateway with no solar has no solar picture.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ #: Below this a flow is drawn idle, so meter noise doesn't animate a wire.
14
+ FLOW_W = 50
15
+ #: Batteries drawn one by one; more than this collapse into one "+N more" card.
16
+ MAX_UNITS = 4
17
+ MAX_SOLAR = 3
18
+
19
+
20
+ def kw(w: Any) -> str:
21
+ return "–" if w is None else f"{abs(w) / 1000:.2f} kW"
22
+
23
+
24
+ def _flow(w: Any, *, reverse: bool = False) -> str:
25
+ if w is None or abs(w) < FLOW_W:
26
+ return "idle"
27
+ forward = (w > 0) != reverse
28
+ return "fwd" if forward else "rev"
29
+
30
+
31
+ def _node(id_, icon, x, y, w, h, label, *, value="", status="", state="ok", frame=True,
32
+ hover="", rows=None, soc=None, card=False, sub="") -> dict:
33
+ """``foot`` is the height including the text under the picture; wires leave below it."""
34
+ foot = h if card else h + 22 + (24 if value else 0) + 18 + (16 if sub else 0) + 8
35
+ n = {"id": id_, "icon": icon, "x": x, "y": y, "w": w, "h": h, "foot": foot, "label": label,
36
+ "value": value, "status": status, "state": state, "frame": frame,
37
+ "hover": hover, "rows": rows or [], "soc": soc, "card": card}
38
+ if sub:
39
+ n["sub"] = sub
40
+ return n
41
+
42
+
43
+ def _wire(a: dict, b: dict, flow: str, *, dc: bool = False, side_a="c", side_b="c") -> dict:
44
+ """An orthogonal wire from ``a`` to ``b``; ``flow`` "fwd" runs a→b, "rev" b→a."""
45
+ def anchor(n, side):
46
+ cx, cy = n["x"] + n["w"] / 2, n["y"] + n["h"] / 2
47
+ return {"l": (n["x"], cy), "r": (n["x"] + n["w"], cy), "t": (cx, n["y"]),
48
+ "b": (cx, n["y"] + n["foot"]), "c": (cx, cy)}[side]
49
+ (x1, y1), (x2, y2) = anchor(a, side_a), anchor(b, side_b)
50
+ if side_a in "lr" and side_b in "lr":
51
+ mx = (x1 + x2) / 2
52
+ d = f"M{x1:g} {y1:g} H{mx:g} V{y2:g} H{x2:g}"
53
+ elif side_a in "tb" and side_b in "tb":
54
+ my = (y1 + y2) / 2
55
+ d = f"M{x1:g} {y1:g} V{my:g} H{x2:g} V{y2:g}"
56
+ elif side_a in "lr":
57
+ d = f"M{x1:g} {y1:g} H{x2:g} V{y2:g}"
58
+ else:
59
+ d = f"M{x1:g} {y1:g} V{y2:g} H{x2:g}"
60
+ return {"from": a["id"], "to": b["id"], "d": d, "flow": flow, "dc": dc}
61
+
62
+
63
+ # ── the objects, independent of arrangement ──────────────────────────────────────
64
+
65
+ def _grid(g: dict) -> dict | None:
66
+ grid = g.get("grid")
67
+ if grid is None or "grid" in (g.get("unavailable") or []):
68
+ return None
69
+ st = grid.get("state", "unknown")
70
+ p = grid.get("power_w")
71
+ if st == "outage":
72
+ return {"icon": "gridout", "label": "Grid", "value": kw(0), "status": "Outage",
73
+ "state": "crit", "hover": "The grid is down. Your batteries are powering the backed-up circuits.",
74
+ "rows": [["State", "Outage"]], "flow": "off"}
75
+ word = ("Importing" if p and p >= FLOW_W else "Exporting" if p and p <= -FLOW_W else "Idle")
76
+ rows = [["State", "Connected" if st != "unknown" else "Unknown"], ["Right now", f"{word} {kw(p)}" if word != "Idle" else "Idle"]]
77
+ if grid.get("voltage_v") is not None:
78
+ rows.append(["Voltage", f"{grid['voltage_v']:.1f} V"])
79
+ if grid.get("freq_hz") is not None:
80
+ rows.append(["Frequency", f"{grid['freq_hz']:.2f} Hz"])
81
+ if st == "spike":
82
+ return {"icon": "gridspike", "label": "Grid", "value": kw(p), "status": "Voltage spike",
83
+ "state": "warn", "hover": "Your aGate reported a grid voltage spike. The grid is still connected.",
84
+ "rows": rows, "flow": _flow(p)}
85
+ hover = {"Importing": f"The grid is up. You are importing {kw(p)}.",
86
+ "Exporting": f"The grid is up. You are exporting {kw(p)}.",
87
+ "Idle": "The grid is up. Almost nothing is flowing to or from it right now."}[word]
88
+ return {"icon": "pylon", "label": "Grid", "value": kw(p), "status": "Connected" if st != "unknown" else "Unknown",
89
+ "state": "ok" if st != "unknown" else "off", "hover": hover, "rows": rows, "flow": _flow(p)}
90
+
91
+
92
+ def _agate(g: dict) -> dict:
93
+ mode, inv = g.get("mode"), g.get("inverter")
94
+ online = g.get("online")
95
+ rows = [["Mode", mode or "–"], ["Inverter", inv or "–"]]
96
+ if g.get("reserve_pct") is not None:
97
+ rows.append(["Backup reserve", f"{g['reserve_pct']:g} %"])
98
+ if not online:
99
+ return {"icon": "battery", "label": g.get("name") or "aGate", "status": "Offline", "state": "off",
100
+ "hover": "The bridge can't reach this aGate right now.", "rows": rows}
101
+ hover = f"Your aGate is in {mode}." if mode else "Your aGate is online."
102
+ if mode == "Emergency Backup":
103
+ hover += " The batteries are kept ready for an outage."
104
+ if inv:
105
+ hover += f" Inverter: {inv.lower()}."
106
+ return {"icon": "battery", "label": g.get("name") or "aGate", "status": mode or "Online",
107
+ "sub": inv or "", "state": "ok", "hover": hover, "rows": rows}
108
+
109
+
110
+ def _home(g: dict) -> dict | None:
111
+ home = g.get("home")
112
+ if home is None:
113
+ return None
114
+ p = home.get("power_w")
115
+ return {"icon": "house", "label": "Home", "value": kw(p), "status": "Using", "state": "ok",
116
+ "hover": f"Your home is using {kw(p)}.", "rows": [["Using now", kw(p)]], "flow": _flow(p)}
117
+
118
+
119
+ def _solars(g: dict) -> list[dict]:
120
+ out = []
121
+ arrays = g.get("solar") or []
122
+ for i, s in enumerate(arrays[:MAX_SOLAR]):
123
+ p = s.get("power_w")
124
+ name = s.get("name") or ("Solar" if len(arrays) == 1 else f"Solar {i + 1}")
125
+ producing = p is not None and p >= FLOW_W
126
+ out.append({"id": s.get("id") or f"solar{i}", "icon": "solar", "label": name, "value": kw(p),
127
+ "status": "Producing" if producing else "Not producing",
128
+ "state": "ok" if producing else "off",
129
+ "hover": f"{name} is producing {kw(p)}." if producing else f"{name} isn't producing right now.",
130
+ "rows": [["Now", kw(p)]], "flow": _flow(p), "dc": s.get("where") == "apower"})
131
+ return out
132
+
133
+
134
+ def _batteries(g: dict) -> list[dict]:
135
+ bat = g.get("battery")
136
+ if bat is None:
137
+ return []
138
+ units = bat.get("units") or []
139
+
140
+ def card(id_, name, soc, p, state="ok"):
141
+ soc = None if soc is None else round(soc) # the ring, the sentence and the table agree
142
+ word = "Discharging" if p and p >= FLOW_W else "Charging" if p and p <= -FLOW_W else "Idle"
143
+ status = {"warn": "Warning", "crit": "Fault", "off": "Offline"}.get(state, word)
144
+ hover = f"{name} is {soc:g} % full" if soc is not None else name
145
+ hover += {"Discharging": f" and discharging {kw(p)}.", "Charging": f" and charging {kw(p)}.",
146
+ "Idle": " and idle."}.get(word, ".") if state == "ok" else f". {status}."
147
+ return {"id": id_, "icon": "apower", "label": name, "value": kw(p) if word != "Idle" else "",
148
+ "status": status, "state": state, "soc": soc, "hover": hover,
149
+ "rows": [["Charge", "–" if soc is None else f"{soc:g} %"], ["Now", status if word == "Idle" or state != "ok" else f"{word} {kw(p)}"]],
150
+ "flow": "off" if state == "off" else _flow(p, reverse=True)}
151
+
152
+ if not units:
153
+ n = bat.get("count")
154
+ name = "Batteries" if (n or 0) > 1 else "Battery"
155
+ if n and n > 1:
156
+ name = f"Batteries ×{n}"
157
+ return [card("battery", name, bat.get("soc_pct"), bat.get("power_w"))]
158
+ cards = [card(u.get("id") or f"bat{i}", u.get("name") or f"Battery {i + 1}", u.get("soc_pct"),
159
+ u.get("power_w"), u.get("state", "ok")) for i, u in enumerate(units)]
160
+ if len(cards) > MAX_UNITS:
161
+ rest = cards[MAX_UNITS - 1:]
162
+ more = card("battery_more", f"+{len(rest)} more", None, sum(c_p for c_p in
163
+ (u.get("power_w") or 0 for u in units[MAX_UNITS - 1:])))
164
+ more["hover"] = f"{len(rest)} more batteries. Open the Battery tab for each one."
165
+ cards = cards[:MAX_UNITS - 1] + [more]
166
+ return cards
167
+
168
+
169
+ def _circuits(g: dict) -> list[dict]:
170
+ out = []
171
+ for i, c in enumerate(g.get("circuits") or []):
172
+ on = c.get("on")
173
+ name = c.get("name") or f"Circuit {i + 1}"
174
+ out.append({"id": f"sc{c.get('id', i + 1)}", "icon": "toggle" if on else "toggleoff",
175
+ "label": name, "value": kw(c["power_w"]) if c.get("power_w") is not None else "",
176
+ "status": "On" if on else "Off" if on is False else "Unknown",
177
+ "state": "ok" if on else "off",
178
+ "hover": f"{name} is {'on' if on else 'off'}." if on is not None else f"{name}: state unknown.",
179
+ "rows": [["State", "On" if on else "Off"]], "flow": "fwd" if on else "idle"})
180
+ return out
181
+
182
+
183
+ def _generator(g: dict) -> dict | None:
184
+ gen = g.get("generator")
185
+ if gen is None:
186
+ return None
187
+ p = gen.get("power_w")
188
+ running = p is not None and p >= FLOW_W
189
+ return {"icon": "gen", "label": "Generator", "value": kw(p) if running else "",
190
+ "status": "Running" if running else gen.get("status") or "Standby",
191
+ "state": "ok" if running else "off",
192
+ "hover": f"The generator is supplying {kw(p)}." if running else "The generator is on standby.",
193
+ "rows": [["State", "Running" if running else "Standby"]], "flow": _flow(p)}
194
+
195
+
196
+ # ── arrangements ──────────────────────────────────────────────────────────────
197
+
198
+ def _place(spec: dict | None, id_, x, y, w, h, *, frame=True, card=False) -> dict | None:
199
+ if spec is None:
200
+ return None
201
+ return _node(spec.get("id", id_), spec["icon"], x, y, w, h, spec["label"],
202
+ value=spec.get("value", ""), status=spec.get("status", ""), state=spec["state"],
203
+ frame=frame, hover=spec["hover"], rows=spec["rows"], soc=spec.get("soc"),
204
+ card=card, sub=spec.get("sub", ""))
205
+
206
+
207
+ def scene(g: dict, orientation: str = "landscape") -> dict:
208
+ """Nodes and wires for one gateway, laid out for ``orientation``."""
209
+ if orientation not in ("landscape", "portrait"):
210
+ raise ValueError(f"orientation: 'landscape' or 'portrait', not {orientation!r}")
211
+ grid, agate, home = _grid(g), _agate(g), _home(g)
212
+ solars, bats, circs, gen = _solars(g), _batteries(g), _circuits(g), _generator(g)
213
+ nodes: list[dict] = []
214
+ wires: list[dict] = []
215
+ land = orientation == "landscape"
216
+
217
+ row = ([gen] if gen else []) + circs
218
+ if land:
219
+ A = _place(agate, "agate", 400, 250, 150, 150)
220
+ G = _place(grid, "grid", 70, 275, 100, 100, frame=False)
221
+ H = _place(home, "home", 425, 30, 100, 100, frame=False)
222
+ S = [_place(s, s["id"], 640 + i * 120, 30, 100, 90, frame=False) for i, s in enumerate(solars)]
223
+ B = [_place(b, b["id"], 660, 250 + i * 104, 280, 88, card=True) for i, b in enumerate(bats)]
224
+ below = max([A["y"] + A["foot"]] + [b["y"] + b["h"] for b in B])
225
+ C = [_place(c, c.get("id", "generator"), 240 + i * 120, below + 50, 90, 80) for i, c in enumerate(row)]
226
+ width = 1000
227
+ else:
228
+ A = _place(agate, "agate", 145, 220, 130, 130)
229
+ G = _place(grid, "grid", 20, 240, 90, 90, frame=False)
230
+ H = _place(home, "home", 310, 240, 90, 90, frame=False)
231
+ S = [_place(s, s["id"], 165 + (i - (len(solars) - 1) / 2) * 120, 20, 90, 80, frame=False)
232
+ for i, s in enumerate(solars)]
233
+ bus = A["y"] + A["foot"] + 12
234
+ B = [_place(b, b["id"], 110, bus + 28 + i * 98, 280, 84, card=True) for i, b in enumerate(bats)]
235
+ cy = (B[-1]["y"] + B[-1]["h"] + 46) if B else bus + 40
236
+ C = [_place(c, c.get("id", "generator"), 30 + (i % 3) * 125, cy + (i // 3) * 150, 90, 80)
237
+ for i, c in enumerate(row)]
238
+ width = 420
239
+
240
+ ax, ay, aw = A["x"], A["y"], A["w"]
241
+ amid, abot = ay + A["h"] / 2, ay + A["foot"]
242
+ nodes.append(A)
243
+ if G:
244
+ nodes.append(G)
245
+ wires.append(_wire(G, A, grid["flow"], side_a="r", side_b="l"))
246
+ if H:
247
+ nodes.append(H)
248
+ wires.append(_wire(A, H, home["flow"], side_a="t", side_b="b") if land
249
+ else _wire(A, H, home["flow"], side_a="r", side_b="l"))
250
+ for k, (spec, n) in enumerate(zip(solars, S)):
251
+ nodes.append(n)
252
+ nx, nb = n["x"] + n["w"] / 2, n["y"] + n["foot"]
253
+ if land: # down, then left into the aGate's right side, above the battery cards
254
+ ty = ay + 20 + k * 14
255
+ d = f"M{nx:g} {nb:g} V{nb + 16 + k * 6:g} H{ax + aw + 40 + k * 8:g} V{ty:g} H{ax + aw:g}"
256
+ else: # straight down into the aGate's top
257
+ d = f"M{nx:g} {nb:g} V{nb + 14:g} H{ax + aw / 2 + (k - (len(S) - 1) / 2) * 16:g} V{ay:g}"
258
+ wires.append({"from": n["id"], "to": A["id"], "flow": spec["flow"], "dc": spec["dc"], "d": d})
259
+ for spec, n in zip(bats, B):
260
+ nodes.append(n)
261
+ my = n["y"] + n["h"] / 2
262
+ if land:
263
+ d = f"M{ax + aw:g} {amid + 30:g} H{n['x'] - 30:g} V{my:g} H{n['x']:g}"
264
+ else: # a bus down the left edge, so no wire crosses a card
265
+ d = f"M{ax + aw / 2:g} {abot:g} V{bus:g} H70 V{my:g} H{n['x']:g}"
266
+ wires.append({"from": A["id"], "to": n["id"], "flow": spec["flow"], "dc": False, "d": d})
267
+ for spec, n in zip(row, C):
268
+ nodes.append(n)
269
+ flow = spec["flow"] if spec is not gen else ("rev" if spec["flow"] == "fwd" else spec["flow"])
270
+ nx, ny = n["x"] + n["w"] / 2, n["y"]
271
+ if land:
272
+ d = f"M{ax + aw / 2:g} {abot:g} V{ny - 22:g} H{nx:g} V{ny:g}"
273
+ else: # a bus down the right edge, clear of the battery cards
274
+ d = f"M{ax + aw / 2:g} {abot:g} V{bus:g} H405 V{ny - 18:g} H{nx:g} V{ny:g}"
275
+ wires.append({"from": A["id"], "to": n["id"], "flow": flow, "dc": False, "d": d})
276
+
277
+ height = max(n["y"] + n["foot"] for n in nodes) + 24
278
+ return {"orientation": orientation, "width": width, "height": height,
279
+ "nodes": nodes, "wires": wires}