@camstack/addon-provider-enpal 0.1.14 → 0.1.16

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.
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """One-shot reader of an Enpal box running firmware 8.5x (Blazor Server).
3
+
4
+ From firmware 8.51 the device tables of `/deviceMessages` are not in the HTML any more:
5
+ the box sends them once, as a RenderBatch, after a Blazor circuit starts. This script does
6
+ what the box's own web page does: load the page, negotiate SignalR, start a circuit, read
7
+ the RenderBatches that follow, and print the sensor rows. It is READ-ONLY: it never
8
+ dispatches a UI event (no checkbox, no button), it only acknowledges renders.
9
+
10
+ The protocol framing and the RenderBatch decoder are `enpal_blazor/` (derolli1976/enpal,
11
+ MIT); the connection sequence follows that component's websocket client.
12
+
13
+ stdin JSON: { "host": "192.168.1.16", "port": 80, "windowMs": 12000, "settleMs": 2000,
14
+ "captureBatches": false }
15
+ stdout JSON: { "ok": true, "rows": [ { "key", "value", "unit", "timestamp" } ],
16
+ "batches": [ "<base64 RenderBatch>" ] # only with captureBatches
17
+ }
18
+ or { "ok": false, "kind": "unreachable|bad-response|empty|protocol", "error": "..." }
19
+ Diagnostics go to stderr. Never reads a credential: the box has none.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import base64
25
+ import json
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+ from typing import Dict, List, Optional
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
32
+
33
+ import aiohttp # noqa: E402
34
+
35
+ from enpal_blazor.protocol import ( # noqa: E402
36
+ ComponentDescriptor,
37
+ decode_messages,
38
+ encode_message,
39
+ extract_application_state,
40
+ extract_blazor_components,
41
+ )
42
+ from enpal_blazor.render_batch import ( # noqa: E402
43
+ extract_changed_rows,
44
+ extract_initial_rows,
45
+ parse_render_batch_strings,
46
+ )
47
+
48
+ # JS calls whose .NET caller dereferences the result: null would tear the circuit down.
49
+ JS_CALL_RESULTS = {
50
+ "mudpopoverHelper.countProviders": "1",
51
+ "Radzen.createChart": '{"left":0,"top":0,"width":800,"height":400}',
52
+ "Radzen.createResizable": '{"left":0,"top":0,"width":800,"height":400}',
53
+ }
54
+
55
+ HTTP_TIMEOUT_S = 15
56
+
57
+ # Rows that mean the device tables arrived (Site Data alone is 4).
58
+ MIN_DEVICE_ROWS = 20
59
+
60
+
61
+ class ReadError(Exception):
62
+ def __init__(self, kind: str, message: str) -> None:
63
+ super().__init__(message)
64
+ self.kind = kind
65
+
66
+
67
+ def log(message: str) -> None:
68
+ print(f"[enpal_worker] {message}", file=sys.stderr, flush=True)
69
+
70
+
71
+ def merge_rows(batches: List[bytes]) -> List[Dict[str, Optional[str]]]:
72
+ """Rows of every batch; a later reading of a key replaces an earlier one."""
73
+ merged: Dict[str, Dict[str, Optional[str]]] = {}
74
+ for raw in batches:
75
+ strings = parse_render_batch_strings(raw)
76
+ for row in extract_initial_rows(strings):
77
+ merged[row["key"]] = row
78
+ for row in extract_changed_rows(strings):
79
+ merged[row["key"]] = row
80
+ return list(merged.values())
81
+
82
+
83
+ class Session:
84
+ def __init__(self, base_url: str) -> None:
85
+ self.base_url = base_url
86
+ self.application_state = ""
87
+ self.components: List[ComponentDescriptor] = []
88
+ self.batches: List[bytes] = []
89
+ self.last_batch_at = 0.0
90
+ self.started = time.monotonic()
91
+ self.closed_reason: Optional[str] = None
92
+ self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
93
+
94
+ async def send(self, msg: list) -> None:
95
+ assert self.ws is not None
96
+ await self.ws.send_bytes(encode_message(msg))
97
+
98
+ async def handle(self, data: bytes) -> None:
99
+ for msg in decode_messages(data):
100
+ if not isinstance(msg, list) or not msg:
101
+ continue
102
+ kind = msg[0]
103
+ if kind == 7:
104
+ self.closed_reason = str(msg[1]) if len(msg) > 1 else "closed by the box"
105
+ continue
106
+ if kind != 1 or len(msg) < 4:
107
+ continue
108
+ target = msg[3]
109
+ args = msg[4] if len(msg) > 4 else []
110
+ if target == "JS.RenderBatch":
111
+ if args:
112
+ await self.send([1, {}, None, "OnRenderCompleted", [args[0], None]])
113
+ if len(args) > 1 and isinstance(args[1], (bytes, bytearray)):
114
+ self.batches.append(bytes(args[1]))
115
+ log(f"render batch {len(self.batches)}: {len(args[1])} bytes at {time.monotonic() - self.started:.1f}s")
116
+ self.last_batch_at = time.monotonic()
117
+ elif target == "JS.BeginInvokeJS" and args:
118
+ identifier = args[1] if len(args) > 1 else ""
119
+ result = JS_CALL_RESULTS.get(identifier, "null")
120
+ await self.send(
121
+ [1, {}, None, "EndInvokeJSFromDotNet", [args[0], True, f"[{args[0]},true,{result}]"]]
122
+ )
123
+ elif target == "JS.Error":
124
+ log(f"circuit error reported by the box: {args[0] if args else None}")
125
+
126
+ async def read_loop(self) -> None:
127
+ assert self.ws is not None
128
+ async for msg in self.ws:
129
+ if msg.type == aiohttp.WSMsgType.BINARY:
130
+ await self.handle(msg.data)
131
+ elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
132
+ self.closed_reason = self.closed_reason or "socket closed"
133
+ break
134
+
135
+ async def open(self, http: aiohttp.ClientSession) -> None:
136
+ async with http.get(f"{self.base_url}/deviceMessages") as resp:
137
+ if resp.status != 200:
138
+ raise ReadError("bad-response", f"HTTP {resp.status} loading /deviceMessages")
139
+ html = await resp.text()
140
+ self.components = extract_blazor_components(html)
141
+ self.application_state = extract_application_state(html)
142
+ if not self.components or not self.application_state:
143
+ raise ReadError("protocol", "the page carries no Blazor components (not an energy-manager box?)")
144
+
145
+ async with http.post(f"{self.base_url}/_blazor/negotiate?negotiateVersion=1", data="") as resp:
146
+ if resp.status != 200:
147
+ raise ReadError("bad-response", f"HTTP {resp.status} during negotiate")
148
+ token = (await resp.json()).get("connectionToken")
149
+ if not token:
150
+ raise ReadError("protocol", "negotiate returned no connectionToken")
151
+
152
+ host = self.base_url.split("://", 1)[1]
153
+ self.ws = await http.ws_connect(f"ws://{host}/_blazor?id={token}")
154
+ await self.ws.send_str('{"protocol":"blazorpack","version":1}\x1e')
155
+ hs = await self.ws.receive()
156
+ text = hs.data.decode("utf-8") if hs.type == aiohttp.WSMsgType.BINARY else str(hs.data)
157
+ if '"error"' in text:
158
+ raise ReadError("protocol", f"handshake refused: {text.strip()}")
159
+
160
+ async def start_circuit(self) -> None:
161
+ await self.send(
162
+ [1, {}, "0", "StartCircuit",
163
+ [self.base_url + "/", self.base_url + "/deviceMessages", "[]", self.application_state]]
164
+ )
165
+ await asyncio.sleep(0.3)
166
+ operations = [
167
+ {
168
+ "type": "add",
169
+ "ssrComponentId": i + 1,
170
+ "marker": {
171
+ "type": c.type,
172
+ "prerenderId": c.prerender_id,
173
+ "key": c.key,
174
+ "sequence": c.sequence,
175
+ "descriptor": c.descriptor,
176
+ "uniqueId": i,
177
+ },
178
+ }
179
+ for i, c in enumerate(self.components)
180
+ ]
181
+ batch = json.dumps({"batchId": 1, "operations": operations})
182
+ await self.send([1, {}, None, "UpdateRootComponents", [batch, self.application_state]])
183
+
184
+
185
+ async def read_box(host: str, port: int, window_s: float, settle_s: float) -> Session:
186
+ session = Session(f"http://{host}:{port}")
187
+ timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT_S)
188
+ async with aiohttp.ClientSession(
189
+ cookie_jar=aiohttp.CookieJar(unsafe=True), timeout=timeout
190
+ ) as http:
191
+ try:
192
+ await session.open(http)
193
+ except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
194
+ raise ReadError("unreachable", f"cannot reach {host}:{port} ({type(err).__name__})") from err
195
+ reader = asyncio.create_task(session.read_loop())
196
+ try:
197
+ await session.start_circuit()
198
+ started = time.monotonic()
199
+ counted = 0
200
+ row_count = 0
201
+ while time.monotonic() - started < window_s:
202
+ await asyncio.sleep(0.25)
203
+ if session.closed_reason is not None or reader.done():
204
+ break
205
+ if len(session.batches) != counted:
206
+ counted = len(session.batches)
207
+ row_count = len(merge_rows(session.batches))
208
+ # The box renders "Site Data" at once and the device tables ~3 s later, so a
209
+ # quiet spell alone is not "done": wait for the device tables (many rows).
210
+ # A box that never sends them is read at the end of the window.
211
+ if row_count >= MIN_DEVICE_ROWS and time.monotonic() - session.last_batch_at >= settle_s:
212
+ break
213
+ finally:
214
+ reader.cancel()
215
+ try:
216
+ await reader
217
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
218
+ pass
219
+ if session.ws is not None and not session.ws.closed:
220
+ await session.ws.close()
221
+ return session
222
+
223
+
224
+ def main() -> int:
225
+ try:
226
+ raw = sys.stdin.read()
227
+ request = json.loads(raw) if raw.strip() else {}
228
+ host = request["host"]
229
+ port = int(request.get("port", 80))
230
+ window_s = float(request.get("windowMs", 12000)) / 1000
231
+ settle_s = float(request.get("settleMs", 2000)) / 1000
232
+ capture = bool(request.get("captureBatches", False))
233
+ if not isinstance(host, str) or not host:
234
+ raise ValueError("host must be a non-empty string")
235
+ except (json.JSONDecodeError, KeyError, ValueError, TypeError) as err:
236
+ print(json.dumps({"ok": False, "kind": "protocol", "error": f"invalid request: {err}"}), flush=True)
237
+ return 1
238
+
239
+ try:
240
+ session = asyncio.run(read_box(host, port, window_s, settle_s))
241
+ rows = merge_rows(session.batches)
242
+ if not rows:
243
+ reason = session.closed_reason or "no render arrived"
244
+ raise ReadError("empty", f"the box rendered no sensor rows ({reason})")
245
+ out: dict = {"ok": True, "rows": rows}
246
+ if capture:
247
+ out["batches"] = [base64.b64encode(b).decode("ascii") for b in session.batches]
248
+ print(json.dumps(out), flush=True)
249
+ return 0
250
+ except ReadError as err:
251
+ print(json.dumps({"ok": False, "kind": err.kind, "error": str(err)}), flush=True)
252
+ return 1
253
+ except Exception as err: # noqa: BLE001 - the bridge always answers JSON
254
+ log(f"unexpected failure: {type(err).__name__}: {err}")
255
+ print(json.dumps({"ok": False, "kind": "protocol", "error": f"{type(err).__name__}: {err}"}), flush=True)
256
+ return 1
257
+
258
+
259
+ if __name__ == "__main__":
260
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ # Runtime deps of the vendored Blazor protocol modules (derolli1976/enpal, MIT).
2
+ aiohttp>=3.9.0
3
+ msgpack>=1.0.7
@@ -0,0 +1,56 @@
1
+ """The worker's row extraction against a RenderBatch captured from a real box (fw 8.52.2).
2
+
3
+ Serial-like values in the capture are masked with X, byte for byte, so the offsets hold.
4
+ """
5
+ import gzip
6
+ import json
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ HERE = Path(__file__).resolve().parent
13
+ sys.path.insert(0, str(HERE))
14
+
15
+ from enpal_worker import merge_rows # noqa: E402
16
+
17
+
18
+ def load_batch() -> bytes:
19
+ return gzip.decompress((HERE / "fixtures" / "render-batch-fw852.bin.gz").read_bytes())
20
+
21
+
22
+ def test_the_fixture_carries_no_serial_certificate_or_imei_like_value():
23
+ raw = load_batch()
24
+ forbidden = {
25
+ "long digit run (serial, IMEI)": rb"\d{12,}",
26
+ "letter plus long digit run": rb"[A-Z]\d{9,}",
27
+ "long hex or colon run (SKI, key id)": rb"[0-9A-Fa-f:]{20,}",
28
+ "PEM block": rb"-----BEGIN",
29
+ "MAC address": rb"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}",
30
+ }
31
+ for label, pattern in forbidden.items():
32
+ assert re.search(pattern, raw) is None, label
33
+
34
+
35
+ def test_the_initial_render_yields_every_device_table():
36
+ rows = {r["key"]: r for r in merge_rows([load_batch()])}
37
+ assert len(rows) > 100
38
+ assert rows["Energy.Battery.Charge.Level"]["value"] == "98.9"
39
+ assert rows["Energy.Battery.Charge.Level"]["unit"] == "%"
40
+ assert rows["Power.DC.Total"]["unit"] == "W"
41
+ assert rows["Heatpump.Power.Consumption.Total"]["value"] == "2991"
42
+
43
+
44
+ def test_a_later_batch_replaces_an_earlier_reading_of_the_same_key():
45
+ first = load_batch()
46
+ rows = merge_rows([first, first])
47
+ assert len({r["key"] for r in rows}) == len(rows)
48
+
49
+
50
+ def test_a_missing_host_answers_json_and_exits_nonzero():
51
+ done = subprocess.run(
52
+ [sys.executable, str(HERE / "enpal_worker.py")],
53
+ input="{}", capture_output=True, text=True, timeout=30,
54
+ )
55
+ assert done.returncode == 1
56
+ assert json.loads(done.stdout)["ok"] is False