@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
package/i18n/en.json
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
"connection.s_identity.title": "Box",
|
|
3
3
|
"connection.s_identity.host.label": "Box host / IP",
|
|
4
4
|
"connection.s_source.title": "Data source",
|
|
5
|
-
"connection.s_source.description": "
|
|
5
|
+
"connection.s_source.description": "The box's own web-page channel needs no credential and is used first. InfluxDB is used only with the access token Enpal support issues; the device page is the last resort.",
|
|
6
6
|
"connection.s_source.source.label": "Source",
|
|
7
|
-
"connection.s_source.source.o_auto.label": "Automatic (InfluxDB, then device page)",
|
|
7
|
+
"connection.s_source.source.o_auto.label": "Automatic (box web channel, InfluxDB, then device page)",
|
|
8
|
+
"connection.s_source.source.o_blazor.label": "Box web channel only (firmware 8.51 and later)",
|
|
8
9
|
"connection.s_source.source.o_influx.label": "InfluxDB only",
|
|
9
10
|
"connection.s_source.source.o_page.label": "Device page only",
|
|
10
11
|
"connection.s_source.token.label": "InfluxDB access token",
|
package/i18n/it.json
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
"connection.s_identity.title": "Box",
|
|
3
3
|
"connection.s_identity.host.label": "Host / IP del box",
|
|
4
4
|
"connection.s_source.title": "Origine dei dati",
|
|
5
|
-
"connection.s_source.description": "
|
|
5
|
+
"connection.s_source.description": "Il canale della pagina web della centralina non richiede credenziali ed è il primo usato. InfluxDB si usa solo con il token rilasciato dal supporto Enpal; la pagina del dispositivo è l'ultima risorsa.",
|
|
6
6
|
"connection.s_source.source.label": "Origine",
|
|
7
|
-
"connection.s_source.source.o_auto.label": "Automatica (InfluxDB, poi pagina
|
|
7
|
+
"connection.s_source.source.o_auto.label": "Automatica (canale web della centralina, InfluxDB, poi pagina)",
|
|
8
|
+
"connection.s_source.source.o_blazor.label": "Solo canale web della centralina (firmware 8.51 e successivi)",
|
|
8
9
|
"connection.s_source.source.o_influx.label": "Solo InfluxDB",
|
|
9
10
|
"connection.s_source.source.o_page.label": "Solo pagina del dispositivo",
|
|
10
11
|
"connection.s_source.token.label": "Token di accesso InfluxDB",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-provider-enpal",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Enpal solar-box device-provider addon for CamStack — PV, battery, grid, house and heat-pump readings from the box's local InfluxDB
|
|
3
|
+
"version": "0.1.16",
|
|
4
|
+
"description": "Enpal solar-box device-provider addon for CamStack — PV, battery, grid, house and heat-pump readings from the box's own web-page channel, its local InfluxDB or its device page",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
7
7
|
"addon",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"category": "providers",
|
|
41
41
|
"name": "Enpal",
|
|
42
42
|
"version": "0.1.0",
|
|
43
|
-
"description": "Add an Enpal solar box as an integration (
|
|
43
|
+
"description": "Add an Enpal solar box as an integration (its own web-page channel, InfluxDB or the device page) and adopt its equipment: photovoltaic, battery, grid, house and heat pump",
|
|
44
44
|
"entry": "./dist/addon.js",
|
|
45
45
|
"icon": "assets/icon.svg",
|
|
46
46
|
"color": "#f5a623",
|
|
@@ -59,15 +59,19 @@
|
|
|
59
59
|
{
|
|
60
60
|
"name": "device-adoption"
|
|
61
61
|
}
|
|
62
|
-
]
|
|
62
|
+
],
|
|
63
|
+
"python": {
|
|
64
|
+
"requirements": "./python/requirements.txt"
|
|
65
|
+
}
|
|
63
66
|
}
|
|
64
67
|
],
|
|
65
68
|
"requires": {
|
|
66
|
-
"server": ">=1.2.
|
|
69
|
+
"server": ">=1.2.829"
|
|
67
70
|
}
|
|
68
71
|
},
|
|
69
72
|
"files": [
|
|
70
73
|
"dist",
|
|
74
|
+
"python",
|
|
71
75
|
"i18n",
|
|
72
76
|
"assets",
|
|
73
77
|
"README.md",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 derolli1976
|
|
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.
|
|
File without changes
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Blazor SignalR protocol helpers"""
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
import msgpack
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
from typing import List, Any, Optional
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Inline ComponentDescriptor to avoid circular imports in standalone mode
|
|
13
|
+
class ComponentDescriptor:
|
|
14
|
+
"""Blazor server component descriptor"""
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.type: str = ""
|
|
17
|
+
self.sequence: int = 0
|
|
18
|
+
self.descriptor: str = ""
|
|
19
|
+
self.prerender_id: str = ""
|
|
20
|
+
self.key: dict = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_blazor_components(html: str) -> List[ComponentDescriptor]:
|
|
24
|
+
"""
|
|
25
|
+
Extract Blazor server components from HTML.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
html: HTML page content
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
List of ComponentDescriptor objects
|
|
32
|
+
"""
|
|
33
|
+
pattern = re.compile(r'<!--Blazor:(\{.+?\})-->')
|
|
34
|
+
matches = pattern.findall(html)
|
|
35
|
+
|
|
36
|
+
components = []
|
|
37
|
+
for match in matches:
|
|
38
|
+
try:
|
|
39
|
+
# Decode escaped characters
|
|
40
|
+
json_str = match.replace(r'\u002B', '+').replace(r'\u002F', '/')
|
|
41
|
+
comp_data = json.loads(json_str)
|
|
42
|
+
|
|
43
|
+
if comp_data.get('type') == 'server':
|
|
44
|
+
comp = ComponentDescriptor()
|
|
45
|
+
comp.type = comp_data['type']
|
|
46
|
+
comp.descriptor = comp_data.get('descriptor', '')
|
|
47
|
+
comp.sequence = comp_data.get('sequence', 0)
|
|
48
|
+
comp.prerender_id = comp_data.get('prerenderId', '')
|
|
49
|
+
|
|
50
|
+
if 'key' in comp_data:
|
|
51
|
+
comp.key = {
|
|
52
|
+
'locationHash': comp_data['key'].get('locationHash', ''),
|
|
53
|
+
'formattedComponentKey': comp_data['key'].get('formattedComponentKey', '')
|
|
54
|
+
}
|
|
55
|
+
components.append(comp)
|
|
56
|
+
except json.JSONDecodeError as e:
|
|
57
|
+
_LOGGER.debug(f"[Enpal WebSocket] Failed to parse component: {e}")
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
return components
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_application_state(html: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Extract Blazor application state from HTML.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
html: HTML page content
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Application state string, or empty string if not found
|
|
72
|
+
"""
|
|
73
|
+
pattern = re.compile(r'<!--Blazor-Server-Component-State:([^-]+)-->')
|
|
74
|
+
match = pattern.search(html)
|
|
75
|
+
return match.group(1).strip() if match else ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_vlq(value: int) -> bytes:
|
|
79
|
+
"""
|
|
80
|
+
Encode integer as Variable-Length Quantity.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
value: Integer to encode
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
VLQ-encoded bytes
|
|
87
|
+
"""
|
|
88
|
+
result = bytearray()
|
|
89
|
+
while True:
|
|
90
|
+
b = value & 0x7F
|
|
91
|
+
value >>= 7
|
|
92
|
+
if value > 0:
|
|
93
|
+
b |= 0x80
|
|
94
|
+
result.append(b)
|
|
95
|
+
if value == 0:
|
|
96
|
+
break
|
|
97
|
+
return bytes(result)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_vlq(reader: io.BytesIO) -> int:
|
|
101
|
+
"""
|
|
102
|
+
Decode Variable-Length Quantity from byte stream.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
reader: BytesIO reader
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Decoded integer
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
EOFError: If reader is exhausted
|
|
112
|
+
"""
|
|
113
|
+
result = 0
|
|
114
|
+
shift = 0
|
|
115
|
+
while True:
|
|
116
|
+
b = reader.read(1)
|
|
117
|
+
if not b:
|
|
118
|
+
raise EOFError()
|
|
119
|
+
byte = b[0]
|
|
120
|
+
result |= (byte & 0x7F) << shift
|
|
121
|
+
if byte & 0x80 == 0:
|
|
122
|
+
break
|
|
123
|
+
shift += 7
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def encode_message(msg: List[Any]) -> bytes:
|
|
128
|
+
"""
|
|
129
|
+
Encode message as MessagePack with VLQ length prefix.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
msg: Message as list
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
VLQ-prefixed MessagePack bytes
|
|
136
|
+
"""
|
|
137
|
+
payload = msgpack.packb(msg)
|
|
138
|
+
result = bytearray()
|
|
139
|
+
result.extend(write_vlq(len(payload)))
|
|
140
|
+
result.extend(payload)
|
|
141
|
+
return bytes(result)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def decode_messages(data: bytes) -> List[List[Any]]:
|
|
145
|
+
"""
|
|
146
|
+
Decode multiple MessagePack messages from byte stream.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
data: Raw bytes containing one or more messages
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of decoded messages (each message is a list)
|
|
153
|
+
"""
|
|
154
|
+
reader = io.BytesIO(data)
|
|
155
|
+
messages = []
|
|
156
|
+
|
|
157
|
+
while reader.tell() < len(data):
|
|
158
|
+
try:
|
|
159
|
+
length = read_vlq(reader)
|
|
160
|
+
payload = reader.read(length)
|
|
161
|
+
msg = msgpack.unpackb(payload, raw=False)
|
|
162
|
+
messages.append(msg)
|
|
163
|
+
except (EOFError, msgpack.exceptions.UnpackException) as e:
|
|
164
|
+
_LOGGER.debug(f"[Enpal WebSocket] Message decode error: {e}")
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
return messages
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def extract_json_from_blazor_data(raw_data: str) -> Optional[str]:
|
|
171
|
+
"""
|
|
172
|
+
Extract JSON data from Blazor setValue call.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
raw_data: Raw data string from blazorMonaco.editor.setValue
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
JSON string if found, None otherwise
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
data_array = json.loads(raw_data)
|
|
182
|
+
if isinstance(data_array, list) and len(data_array) >= 2:
|
|
183
|
+
return data_array[1]
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
pass
|
|
186
|
+
return None
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Incremental parser for Blazor RenderBatch binary diffs.
|
|
2
|
+
|
|
3
|
+
Instead of HTTP re-scraping ``/deviceMessages`` on every server push (which
|
|
4
|
+
forces the Enpal box to re-render the whole 130+ sensor page roughly every
|
|
5
|
+
five seconds), this module extracts only the *changed* sensor rows directly
|
|
6
|
+
from the RenderBatch binary payload that the box already sends us.
|
|
7
|
+
|
|
8
|
+
Binary layout (Blazor ``RenderBatchWriter``)
|
|
9
|
+
--------------------------------------------
|
|
10
|
+
The last 20 bytes of a RenderBatch are five little-endian ``int32`` section
|
|
11
|
+
offsets. The final one points at the string table, which is an array of
|
|
12
|
+
``int32`` offsets, each pointing at a VLQ-length-prefixed UTF-8 string.
|
|
13
|
+
|
|
14
|
+
For the Enpal ``/deviceMessages`` page every updated table row is emitted, in
|
|
15
|
+
DOM order, as one of the following runs of strings::
|
|
16
|
+
|
|
17
|
+
# firmware 8.50
|
|
18
|
+
'dp-flash', '<Key>', '<ws>', '<value>'[, '<unit>'], '<ws>', '<timestamp>'
|
|
19
|
+
|
|
20
|
+
# firmware 8.51 (extra Notes column, css helper classes)
|
|
21
|
+
'dp-flash[ pi-row-validation]', '<Key>', '<value>'[, '<unit>'],
|
|
22
|
+
'text-nowrap', ['style',] 'width: 1%;', '<timestamp>',
|
|
23
|
+
'pi-note-cell', 'pi-note-text', '0', '<note text>'
|
|
24
|
+
|
|
25
|
+
# firmware 8.51, row without a reading (note only)
|
|
26
|
+
'dp-flash pi-row-validation', '<Key>', ['colspan',] '3', 'pi-note-cell', ...
|
|
27
|
+
|
|
28
|
+
where ``<ws>`` is a pure-whitespace separator. Recovering the changed rows is
|
|
29
|
+
therefore a simple linear scan over the decoded string table - no virtual DOM
|
|
30
|
+
reconstruction required.
|
|
31
|
+
|
|
32
|
+
This is a best-effort fast path. Anything it cannot resolve (ambiguous keys,
|
|
33
|
+
brand-new sensors, malformed frames) is left to the periodic full HTML scrape
|
|
34
|
+
the coordinator already performs, so the worst case degrades gracefully to
|
|
35
|
+
plain interval polling.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import io
|
|
39
|
+
import re
|
|
40
|
+
import struct
|
|
41
|
+
import logging
|
|
42
|
+
from typing import Dict, List, Optional
|
|
43
|
+
|
|
44
|
+
_LOGGER = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Strings that frame a sensor row but carry no data themselves.
|
|
47
|
+
_STRUCTURAL = {"onchange", "tr", "td", "class", "dp-flash", ""}
|
|
48
|
+
|
|
49
|
+
# 8.51: end of the value cell / start of a note-only row.
|
|
50
|
+
_VALUE_END = {"text-nowrap"}
|
|
51
|
+
_NOTE_MARKERS = {"pi-note-cell", "colspan"}
|
|
52
|
+
|
|
53
|
+
# 8.51 timestamps are time-only ("18:19:44.00"), 8.50 ones are full
|
|
54
|
+
# date-times; both contain an HH:MM:SS run.
|
|
55
|
+
_TIME_RE = re.compile(r"\d{1,2}:\d{2}:\d{2}")
|
|
56
|
+
|
|
57
|
+
# Sensor keys are dotted CamelCase identifiers ("Energy.Battery.Charge.Level",
|
|
58
|
+
# "LTE.RSSI"). The uppercase first segment keeps version strings ("v6.3") and
|
|
59
|
+
# numeric fragments ("226.3") out; non-row strings that happen to match (e.g.
|
|
60
|
+
# the assembly name) fail the value-termination check in _parse_row_body.
|
|
61
|
+
_KEY_RE = re.compile(r"^[A-Z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$")
|
|
62
|
+
|
|
63
|
+
_MAX_VALUE_TOKENS = 4
|
|
64
|
+
|
|
65
|
+
# Values longer than this are not sensor readings we want to patch
|
|
66
|
+
# incrementally (e.g. the inverter system-state bitfield, which the HTML
|
|
67
|
+
# parser splits into several sensors). Leave those to the full scrape.
|
|
68
|
+
_MAX_VALUE_LEN = 200
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_vlq(reader: io.BytesIO) -> int:
|
|
72
|
+
"""Decode a 7-bit variable-length quantity."""
|
|
73
|
+
result = 0
|
|
74
|
+
shift = 0
|
|
75
|
+
while True:
|
|
76
|
+
b = reader.read(1)
|
|
77
|
+
if not b:
|
|
78
|
+
raise EOFError()
|
|
79
|
+
byte = b[0]
|
|
80
|
+
result |= (byte & 0x7F) << shift
|
|
81
|
+
if byte & 0x80 == 0:
|
|
82
|
+
break
|
|
83
|
+
shift += 7
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_render_batch_strings(raw: bytes) -> List[str]:
|
|
88
|
+
"""Decode the Blazor RenderBatch string table to an ordered list of strings.
|
|
89
|
+
|
|
90
|
+
Returns an empty list if the buffer is too small or malformed - callers
|
|
91
|
+
should treat that as "no incremental data" and rely on the full scrape.
|
|
92
|
+
"""
|
|
93
|
+
if not raw or len(raw) < 20:
|
|
94
|
+
return []
|
|
95
|
+
try:
|
|
96
|
+
string_table_offset = struct.unpack_from("<i", raw, len(raw) - 4)[0]
|
|
97
|
+
if string_table_offset < 0 or string_table_offset > len(raw) - 20:
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
table_region = raw[string_table_offset:len(raw) - 20]
|
|
101
|
+
count = len(table_region) // 4
|
|
102
|
+
if count <= 0:
|
|
103
|
+
return []
|
|
104
|
+
offsets = struct.unpack_from("<%di" % count, table_region, 0)
|
|
105
|
+
|
|
106
|
+
strings: List[str] = []
|
|
107
|
+
for off in offsets:
|
|
108
|
+
if off < 0 or off >= len(raw):
|
|
109
|
+
strings.append("")
|
|
110
|
+
continue
|
|
111
|
+
reader = io.BytesIO(raw[off:])
|
|
112
|
+
length = _read_vlq(reader)
|
|
113
|
+
strings.append(reader.read(length).decode("utf-8", "replace"))
|
|
114
|
+
return strings
|
|
115
|
+
except Exception as e: # noqa: BLE001 - never let a bad frame break the loop
|
|
116
|
+
_LOGGER.debug("[Enpal RenderBatch] string-table decode failed: %s", e)
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_ws(s: str) -> bool:
|
|
121
|
+
return s.strip() == ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_row_class(s: str) -> bool:
|
|
125
|
+
return s == "dp-flash" or s.startswith("dp-flash ")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _find_timestamp(strings: List[str], start: int) -> Optional[str]:
|
|
129
|
+
"""Take the first time-looking string after the value cell."""
|
|
130
|
+
n = len(strings)
|
|
131
|
+
for k in range(start, min(start + 6, n)):
|
|
132
|
+
s = strings[k]
|
|
133
|
+
if s in _NOTE_MARKERS or _is_row_class(s):
|
|
134
|
+
return None
|
|
135
|
+
if _is_ws(s) or s in _VALUE_END or s == "style" or s.endswith(";"):
|
|
136
|
+
continue
|
|
137
|
+
if _TIME_RE.search(s):
|
|
138
|
+
return s
|
|
139
|
+
return None
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_row_body(
|
|
144
|
+
strings: List[str], j: int
|
|
145
|
+
) -> tuple:
|
|
146
|
+
"""Parse value tokens and timestamp for the sensor key at index ``j``.
|
|
147
|
+
|
|
148
|
+
Returns ``(row, next_i)``. ``row`` is ``None`` when the key carries no
|
|
149
|
+
readable value (note-only row, truncated frame, run into the next row).
|
|
150
|
+
"""
|
|
151
|
+
n = len(strings)
|
|
152
|
+
# Collect value tokens (value, optional unit). 8.50 separates them
|
|
153
|
+
# with whitespace strings, 8.51 with css helper classes.
|
|
154
|
+
k = j + 1
|
|
155
|
+
if k < n and _is_ws(strings[k]):
|
|
156
|
+
k += 1 # single leading separator (8.50)
|
|
157
|
+
value_tokens: List[str] = []
|
|
158
|
+
end = None
|
|
159
|
+
while k < n:
|
|
160
|
+
s = strings[k]
|
|
161
|
+
if _is_ws(s) or s in _VALUE_END:
|
|
162
|
+
end = "value"
|
|
163
|
+
break
|
|
164
|
+
if s in _NOTE_MARKERS:
|
|
165
|
+
end = "note"
|
|
166
|
+
break
|
|
167
|
+
if _is_row_class(s) or _KEY_RE.match(s):
|
|
168
|
+
# Ran into the next row (sensor values never look like keys).
|
|
169
|
+
end = "row"
|
|
170
|
+
break
|
|
171
|
+
if len(value_tokens) >= _MAX_VALUE_TOKENS:
|
|
172
|
+
end = "overflow"
|
|
173
|
+
break
|
|
174
|
+
value_tokens.append(s)
|
|
175
|
+
k += 1
|
|
176
|
+
|
|
177
|
+
if end != "value":
|
|
178
|
+
# Note-only row, truncated frame or run into the next row: no
|
|
179
|
+
# reading to apply.
|
|
180
|
+
return None, (k if end == "row" else k + 1)
|
|
181
|
+
|
|
182
|
+
row = {
|
|
183
|
+
"key": strings[j],
|
|
184
|
+
"value": value_tokens[0] if value_tokens else "",
|
|
185
|
+
"unit": value_tokens[1] if len(value_tokens) > 1 else None,
|
|
186
|
+
"timestamp": _find_timestamp(strings, k + 1),
|
|
187
|
+
}
|
|
188
|
+
return row, k + 1
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def extract_changed_rows(strings: List[str]) -> List[Dict[str, Optional[str]]]:
|
|
192
|
+
"""Extract changed sensor rows from a decoded string table.
|
|
193
|
+
|
|
194
|
+
Returns a list of ``{"key", "value", "unit", "timestamp"}`` dicts, one per
|
|
195
|
+
``dp-flash`` row that looks like a sensor (dotted key). Rows without a
|
|
196
|
+
reading (8.51 note-only rows) are skipped so the entity keeps its last
|
|
197
|
+
value.
|
|
198
|
+
"""
|
|
199
|
+
rows: List[Dict[str, Optional[str]]] = []
|
|
200
|
+
n = len(strings)
|
|
201
|
+
i = 0
|
|
202
|
+
while i < n:
|
|
203
|
+
if not _is_row_class(strings[i]):
|
|
204
|
+
i += 1
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# The sensor key is the next non-structural string.
|
|
208
|
+
j = i + 1
|
|
209
|
+
while j < n and (strings[j] in _STRUCTURAL or _is_ws(strings[j])):
|
|
210
|
+
j += 1
|
|
211
|
+
if j >= n:
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
key = strings[j]
|
|
215
|
+
# Sensor keys are dotted identifiers (e.g. "Battery.Unit.1.Voltage").
|
|
216
|
+
if not key or "." not in key or " " in key:
|
|
217
|
+
i = j if _is_row_class(key) else j + 1
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
row, i = _parse_row_body(strings, j)
|
|
221
|
+
if row is not None:
|
|
222
|
+
rows.append(row)
|
|
223
|
+
|
|
224
|
+
return rows
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def extract_initial_rows(
|
|
228
|
+
strings: List[str], known_keys=None
|
|
229
|
+
) -> List[Dict[str, Optional[str]]]:
|
|
230
|
+
"""Extract sensor rows from a full-page render (no ``dp-flash`` markers).
|
|
231
|
+
|
|
232
|
+
On firmware 8.51 the HTTP response of ``/deviceMessages`` contains only
|
|
233
|
+
the pre-rendered "Site Data" card; every device table arrives once as a
|
|
234
|
+
big initial RenderBatch right after the circuit starts (and again after a
|
|
235
|
+
page toggle re-render). Those rows render with an empty css class, so
|
|
236
|
+
:func:`extract_changed_rows` cannot see them. Row starts are detected by
|
|
237
|
+
the dotted-key pattern; ``known_keys`` optionally accepts additional keys
|
|
238
|
+
that do not match it.
|
|
239
|
+
"""
|
|
240
|
+
rows: List[Dict[str, Optional[str]]] = []
|
|
241
|
+
n = len(strings)
|
|
242
|
+
i = 0
|
|
243
|
+
while i < n:
|
|
244
|
+
s = strings[i]
|
|
245
|
+
if not isinstance(s, str) or not (
|
|
246
|
+
_KEY_RE.match(s) or (known_keys is not None and s in known_keys)
|
|
247
|
+
):
|
|
248
|
+
i += 1
|
|
249
|
+
continue
|
|
250
|
+
row, i = _parse_row_body(strings, i)
|
|
251
|
+
if row is not None:
|
|
252
|
+
rows.append(row)
|
|
253
|
+
return rows
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def extract_event_handlers(raw: bytes) -> Dict[str, int]:
|
|
257
|
+
"""Map DOM element ids to Blazor event handler IDs.
|
|
258
|
+
|
|
259
|
+
Scans the reference-frames section of a RenderBatch for attribute frames
|
|
260
|
+
(frame type 3, 20 bytes each: type, name string index, value string index,
|
|
261
|
+
``int64`` event handler id). Attributes belong to the element opened by
|
|
262
|
+
the preceding non-attribute frame, so an ``id`` attribute and an event
|
|
263
|
+
handler attribute within the same run identify one clickable element.
|
|
264
|
+
|
|
265
|
+
Used to find the ``showUnsupported_*`` / ``showInternal_*`` checkboxes on
|
|
266
|
+
firmware 8.51 so the client can enable them on its own circuit.
|
|
267
|
+
|
|
268
|
+
Returns an empty dict on malformed frames - callers treat that as
|
|
269
|
+
"nothing to click".
|
|
270
|
+
"""
|
|
271
|
+
strings = parse_render_batch_strings(raw)
|
|
272
|
+
if not strings or len(raw) < 24:
|
|
273
|
+
return {}
|
|
274
|
+
try:
|
|
275
|
+
footer = struct.unpack_from("<5i", raw, len(raw) - 20)
|
|
276
|
+
frames_offset, frames_end = footer[1], footer[2]
|
|
277
|
+
if not (0 <= frames_offset < frames_end <= len(raw)):
|
|
278
|
+
return {}
|
|
279
|
+
|
|
280
|
+
pos = frames_offset
|
|
281
|
+
count = struct.unpack_from("<i", raw, pos)[0]
|
|
282
|
+
pos += 4
|
|
283
|
+
|
|
284
|
+
handlers: Dict[str, int] = {}
|
|
285
|
+
current_id: Optional[str] = None
|
|
286
|
+
current_event = 0
|
|
287
|
+
|
|
288
|
+
def _flush():
|
|
289
|
+
nonlocal current_id, current_event
|
|
290
|
+
if current_id and current_event > 0:
|
|
291
|
+
handlers[current_id] = current_event
|
|
292
|
+
current_id, current_event = None, 0
|
|
293
|
+
|
|
294
|
+
for _ in range(count):
|
|
295
|
+
if pos + 20 > frames_end:
|
|
296
|
+
break
|
|
297
|
+
frame_type = struct.unpack_from("<i", raw, pos)[0]
|
|
298
|
+
if frame_type == 3: # attribute frame
|
|
299
|
+
name_idx = struct.unpack_from("<i", raw, pos + 4)[0]
|
|
300
|
+
value_idx = struct.unpack_from("<i", raw, pos + 8)[0]
|
|
301
|
+
event_id = struct.unpack_from("<q", raw, pos + 12)[0]
|
|
302
|
+
name = strings[name_idx] if 0 <= name_idx < len(strings) else None
|
|
303
|
+
if name == "id" and 0 <= value_idx < len(strings):
|
|
304
|
+
current_id = strings[value_idx]
|
|
305
|
+
elif event_id > 0:
|
|
306
|
+
current_event = event_id
|
|
307
|
+
else:
|
|
308
|
+
_flush()
|
|
309
|
+
pos += 20
|
|
310
|
+
_flush()
|
|
311
|
+
return handlers
|
|
312
|
+
except Exception as e: # noqa: BLE001 - never let a bad frame break the loop
|
|
313
|
+
_LOGGER.debug("[Enpal RenderBatch] event-handler scan failed: %s", e)
|
|
314
|
+
return {}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def extract_change_handler_ids(raw: bytes) -> List[int]:
|
|
318
|
+
"""Ordered event handler ids of all ``onchange`` attribute frames.
|
|
319
|
+
|
|
320
|
+
Firmware 8.51 disposes and recreates every checkbox handler on each
|
|
321
|
+
re-render. The diff batches carry the fresh handlers in stable DOM order
|
|
322
|
+
but without ``id`` attributes, so the position in this list is the only
|
|
323
|
+
way to map them back to a checkbox learned from the initial batch.
|
|
324
|
+
|
|
325
|
+
Returns an empty list on malformed frames.
|
|
326
|
+
"""
|
|
327
|
+
strings = parse_render_batch_strings(raw)
|
|
328
|
+
if not strings or len(raw) < 24:
|
|
329
|
+
return []
|
|
330
|
+
try:
|
|
331
|
+
footer = struct.unpack_from("<5i", raw, len(raw) - 20)
|
|
332
|
+
frames_offset, frames_end = footer[1], footer[2]
|
|
333
|
+
if not (0 <= frames_offset < frames_end <= len(raw)):
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
pos = frames_offset
|
|
337
|
+
count = struct.unpack_from("<i", raw, pos)[0]
|
|
338
|
+
pos += 4
|
|
339
|
+
|
|
340
|
+
ordered: List[int] = []
|
|
341
|
+
for _ in range(count):
|
|
342
|
+
if pos + 20 > frames_end:
|
|
343
|
+
break
|
|
344
|
+
frame_type = struct.unpack_from("<i", raw, pos)[0]
|
|
345
|
+
if frame_type == 3: # attribute frame
|
|
346
|
+
name_idx = struct.unpack_from("<i", raw, pos + 4)[0]
|
|
347
|
+
event_id = struct.unpack_from("<q", raw, pos + 12)[0]
|
|
348
|
+
name = strings[name_idx] if 0 <= name_idx < len(strings) else None
|
|
349
|
+
if name == "onchange" and event_id > 0:
|
|
350
|
+
ordered.append(event_id)
|
|
351
|
+
pos += 20
|
|
352
|
+
return ordered
|
|
353
|
+
except Exception as e: # noqa: BLE001 - never let a bad frame break the loop
|
|
354
|
+
_LOGGER.debug("[Enpal RenderBatch] change-handler scan failed: %s", e)
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def is_patchable_value(value: Optional[str]) -> bool:
|
|
359
|
+
"""Whether a raw RenderBatch value should be applied on the fast path.
|
|
360
|
+
|
|
361
|
+
Empty values and oversized blobs are skipped and left to the full scrape.
|
|
362
|
+
"""
|
|
363
|
+
if value is None or value == "":
|
|
364
|
+
return False
|
|
365
|
+
if len(value) > _MAX_VALUE_LEN:
|
|
366
|
+
return False
|
|
367
|
+
return True
|