@camstack/addon-provider-enpal 0.1.15 → 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.
- package/CHANGELOG.md +10 -0
- package/README.md +19 -18
- package/dist/addon.js +1219 -152
- package/dist/addon.mjs +1219 -152
- package/i18n/en.json +3 -2
- package/i18n/it.json +3 -2
- package/package.json +9 -5
- package/python/enpal_blazor/LICENSE +21 -0
- package/python/enpal_blazor/__init__.py +0 -0
- package/python/enpal_blazor/protocol.py +186 -0
- package/python/enpal_blazor/render_batch.py +367 -0
- package/python/enpal_worker.py +260 -0
- package/python/fixtures/render-batch-fw852.bin.gz +0 -0
- package/python/requirements.txt +3 -0
- package/python/test_enpal_worker.py +56 -0
|
@@ -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())
|
|
Binary file
|
|
@@ -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
|