@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/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": "InfluxDB needs the access token Enpal support issues (ask in the Enpal app chat). Without it the box device page is used.",
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": "InfluxDB richiede il token di accesso rilasciato dal supporto Enpal (chiedilo nella chat dell'app Enpal). Senza token si usa la pagina del dispositivo.",
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 del dispositivo)",
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.15",
4
- "description": "Enpal solar-box device-provider addon for CamStack — PV, battery, grid, house and heat-pump readings from the box's local InfluxDB (device page as fallback)",
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 (InfluxDB, device page as fallback) and adopt its equipment: photovoltaic, battery, grid, house and heat pump",
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.827"
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