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.
- franklinwh_system_view-0.1.0/LICENSE +21 -0
- franklinwh_system_view-0.1.0/PKG-INFO +57 -0
- franklinwh_system_view-0.1.0/README.md +43 -0
- franklinwh_system_view-0.1.0/pyproject.toml +28 -0
- franklinwh_system_view-0.1.0/setup.cfg +4 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/__init__.py +40 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/demo.py +69 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/au_single_phase.json +7 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/offline.json +4 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/six_apowers.json +10 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/us_backup_no_solar.json +8 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/us_outage.json +7 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/us_tou_two_apowers_solar.json +8 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/layout.py +279 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/schema.py +79 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/static/system-view.css +82 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view/static/system-view.js +243 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/PKG-INFO +57 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/SOURCES.txt +23 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/dependency_links.txt +1 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/requires.txt +3 -0
- franklinwh_system_view-0.1.0/src/franklinwh_system_view.egg-info/top_level.txt +1 -0
- franklinwh_system_view-0.1.0/tests/test_layout.py +113 -0
- franklinwh_system_view-0.1.0/tests/test_page.py +162 -0
- 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,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}}]}
|
franklinwh_system_view-0.1.0/src/franklinwh_system_view/fixtures/us_tou_two_apowers_solar.json
ADDED
|
@@ -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}
|