iobroker.idm-multitalent_002 2.1.2 → 2.1.3
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/README.md +72 -28
- package/io-package.json +14 -14
- package/lib/datablocks/README.md +12 -6
- package/lib/idm-protocol.js +97 -43
- package/lib/idm-protocol.test.js +92 -2
- package/lib/idm-session.js +54 -7
- package/lib/idm-session.test.js +113 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Currently following versions are supported (if your version is not listed but yo
|
|
|
30
30
|
|
|
31
31
|
You need a Ethernet to RS422 converter to connect to the multitalent control.
|
|
32
32
|
**Note** that you have to connect ground/shield of your converter to the ground of the control/heatpump in order to prevent electric influences on the sensor readings.
|
|
33
|
-
There are sensor values and settings values;
|
|
33
|
+
There are sensor values and settings values; the adapter alternates between the two groups turn by turn, so both stay about equally up to date. The adapter reads a whole group (all sensor values, or all settings values) together in one request once every block's exact reply length for that group has been CONFIRMED from real traffic - even for firmwares with hand-verified lengths built in, a short startup phase always re-confirms each one against the actual hardware first (one matching read is enough there; a firmware with no built-in lengths at all needs two consecutive matching reads instead, since it has nothing to start from), no dedicated measurement pass required either way, just the readings the adapter would be taking anyway. Every firmware ends up on the faster combined-request path this way; only how many polling cycles that startup phase takes depends on whether its lengths were already known in advance (see the Architecture section below for the exact schedule). If a combined reply is ever misread, the adapter forgets that group's confirmed lengths and re-confirms it from scratch rather than continuing to trust a value that just proved wrong.
|
|
34
34
|
Values you change yourself are sent to the heat pump right away; the state's acknowledgment only follows once that value is actually read back from the heat pump on its next turn, so it can take a little while to show up.
|
|
35
35
|
|
|
36
36
|
During bootup of the heatpump control (e.g. after a power loss) no values should be polled. This is currently **NOT** ensured by the adapter. So you **manually** need to **stop** it. If the control of the heatpump did not start due to the adapter then simply stop the adapter and power cycle the control. This should fix the problem. Afterwards you can start the adapter again. I implemented a delayed switch-on of the serial server. This also mitigates the problem.
|
|
@@ -56,6 +56,13 @@ Example screenshots of objects:
|
|
|
56
56
|

|
|
57
57
|
|
|
58
58
|
## Changelog
|
|
59
|
+
### 2.1.3 (2026-09-11)
|
|
60
|
+
* (zloe) every data block's wire length - even a hand-verified one built into a firmware's data block definition - now always needs at least one confirming read against the actual connected hardware before multi-block requests trust it, exactly like a value learned from scratch or restored from a previous run; previously a built-in value was trusted outright, forever, with no live check at all. This costs nothing extra in the normal case (that one confirming read happens as part of the startup phase every firmware already goes through) and means every firmware benefits equally from the same safety net: if a multi-block reply is ever misread (lost sync - an unrecognized block id, a block shorter than expected, or leftover trailing bytes), the adapter now forgets every wire length for that whole group and re-confirms it from scratch instead of continuing to (mis)use a length that just proved wrong
|
|
61
|
+
* (zloe) sensor and settings turns now alternate strictly 1:1 (previously sensor got 2 turns for every 1 settings turn) - the 2:1 bias dated from when a settings turn could take much longer than a sensor turn to actually finish; since 2.1.2 a turn always collects its whole group before yielding either way, so the old bias no longer bought sensor anything and settings now gets its fair share of turns too
|
|
62
|
+
* (zloe) the maximum frame length the adapter will buffer before giving up on a stuck/noisy connection is reduced from 2048 to 1024 hex characters (~512 bytes) - still more than double the largest real combined multi-block reply seen so far (S_H726100's 7-block settings group, ~442 hex characters), while catching runaway noise sooner
|
|
63
|
+
* (zloe) new `info`-level log lines, once per connection: how many blocks in each of the sensor/settings groups still need a confirming measurement before that group can switch to multi-block requests (0 means it starts on multi-block requests right away)
|
|
64
|
+
* (zloe) fix: the intro's description of sensor/settings polling still implied a firmware's built-in wire lengths were trusted immediately and that sensor was read twice as often as settings - both are addressed by the two changes above
|
|
65
|
+
|
|
59
66
|
### 2.1.2 (2026-09-10)
|
|
60
67
|
* (zloe) fix: 2.1.0's interleaving made a multi-block group's turn give up after exactly one `0171`/`0172` attempt and yield to the other group, even if that attempt hadn't found every block yet - on real hardware, where settings typically needs several re-asks to fully arrive, this meant each of those re-asks now had to wait through two full sensor turns and a connection resync in between, so a full settings refresh actually got close to as slow as before 2.0.0 despite sensor freshness genuinely improving. A group's turn now again collects the whole group - re-asking with a bare `0172` at an adaptive backoff, exactly like the original (2.0.0) settings-only mechanism, just extended to sensor too - before yielding to the next turn in the poll pattern (see the Architecture section)
|
|
61
68
|
|
|
@@ -230,16 +237,21 @@ dropped as too noisy; only this line remains.)
|
|
|
230
237
|
#### Poll pattern: sensor and settings
|
|
231
238
|
|
|
232
239
|
Every call to `request_data()` is one "turn", and which of the two groups (sensor or settings) a
|
|
233
|
-
turn is for comes from `IdmSession#pollPattern` (`['sensor', '
|
|
234
|
-
`pollPatternIndex`):
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
turn is for comes from `IdmSession#pollPattern` (`['sensor', 'settings']`, cycled via
|
|
241
|
+
`pollPatternIndex`): simple 1:1 alternation, sensor first - both after connecting and after every
|
|
242
|
+
settings turn - so the very first turn after connecting, and every settings turn, is bracketed by a
|
|
243
|
+
fresh sensor read rather than opening the connection with the slower-changing settings data. An
|
|
244
|
+
earlier version gave sensor two turns for every one settings turn, back when a settings turn could
|
|
245
|
+
take several times longer than a sensor turn to actually finish (many re-asks against real
|
|
246
|
+
hardware); now that a turn always collects its whole group before yielding either way (see below),
|
|
247
|
+
that imbalance no longer buys sensor anything extra, so plain alternation is both simpler and gives
|
|
248
|
+
settings its fair share too. Within a turn, each group independently uses either the classic
|
|
249
|
+
one-block-at-a-time round-robin (unchanged since the very first version) or a multi-block request
|
|
250
|
+
for the whole group at once, whichever it currently qualifies for - see the next two sections.
|
|
251
|
+
Where a group is multi-block-capable, its turn collects that WHOLE group - every one of its blocks
|
|
252
|
+
actually found - before the next turn in the pattern begins; it is not interrupted partway through
|
|
253
|
+
(see the next section for why), so the pattern is what settles how *often* each group gets a turn,
|
|
254
|
+
not how long any one turn takes.
|
|
243
255
|
|
|
244
256
|
#### Multi-block requests, per group
|
|
245
257
|
|
|
@@ -255,13 +267,13 @@ requiring the request to be repeated until everything has actually come back. Be
|
|
|
255
267
|
multi-block request path is strictly opt-in, gated independently for each of the two groups:
|
|
256
268
|
`IdmProtocol#firmwareSupportsMultiBlockRequestsForBlocks()` (and its `firmwareSupportsMultiBlockRequests()`
|
|
257
269
|
/`firmwareSupportsMultiBlockRequestsForSensors()` convenience wrappers for the settings/sensor
|
|
258
|
-
groups) only returns true once every one of that group's blocks has a TRUSTED wire length -
|
|
270
|
+
groups) only returns true once every one of that group's blocks has a TRUSTED wire length - always
|
|
271
|
+
CONFIRMED from real traffic (see "wireLength learning" below), whether it started from nothing, from
|
|
259
272
|
an explicit, hand-verified `wireLength` in its data block definition (see
|
|
260
|
-
[`lib/datablocks/README.md`](lib/datablocks/README.md)), or
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
unaffected.
|
|
273
|
+
[`lib/datablocks/README.md`](lib/datablocks/README.md)), or from a value restored from a previous
|
|
274
|
+
run. A firmware's two groups can be in different states (e.g. settings qualified while sensor is
|
|
275
|
+
still being confirmed, or vice versa); whichever group doesn't (yet) qualify keeps using the plain
|
|
276
|
+
one-block-per-turn round-robin, completely unaffected.
|
|
265
277
|
|
|
266
278
|
Where a group is multi-block-capable, `IdmSession#beginGroupCollection()` requests every block in
|
|
267
279
|
that group at once instead of just one - a single `0171` naming the whole group - reusing the very
|
|
@@ -282,6 +294,20 @@ stragglers for now (falling back to idle exactly as if it had completed) and sta
|
|
|
282
294
|
fresh - asking for every one of that group's blocks again - next time that group's turn comes
|
|
283
295
|
around, so nothing is permanently lost, only deferred.
|
|
284
296
|
|
|
297
|
+
A reply that can't be fully parsed at all (`IdmProtocol#parse_multi_block_reply()`'s "lost sync" -
|
|
298
|
+
an unrecognized block id, a block truncated shorter than its wire length, or trailing bytes left
|
|
299
|
+
over after the last complete block) is treated differently from a routine partial reply: it means
|
|
300
|
+
one of the group's wire lengths is no longer correct, though not which one, so
|
|
301
|
+
`IdmSession#handleMultiBlockDataReply()` immediately abandons the collection and calls
|
|
302
|
+
`IdmProtocol#forgetMeasuredWireLengths()` to discard EVERY one of that group's wire lengths - both
|
|
303
|
+
confirmed values and unconfirmed seeds, deliberately not re-seeding from the same (possibly wrong)
|
|
304
|
+
built-in value - before falling back to idle. `firmwareSupportsMultiBlockRequestsForBlocks()` then
|
|
305
|
+
correctly returns false for the group, so its very next turn drops back to the classic
|
|
306
|
+
one-block-at-a-time round-robin and re-confirms every block from scratch, exactly like a block seen
|
|
307
|
+
for the first time ever. This benefits every firmware equally, not just the ones with hand-verified
|
|
308
|
+
lengths: whatever the length's original source, a wrong assumption never lingers past the collection
|
|
309
|
+
it broke.
|
|
310
|
+
|
|
285
311
|
This deliberately mirrors how the very first (2.0.0) version of this feature worked, rather than
|
|
286
312
|
resending a fresh `0171` on every re-ask and force-yielding to the other group after a single
|
|
287
313
|
attempt regardless of whether the collection had finished (as a brief interim design in 2.1.0 did):
|
|
@@ -303,17 +329,35 @@ cost, for any block that doesn't already have a trusted length. A measurement al
|
|
|
303
329
|
immediately, though - `IdmProtocol#recordMeasuredWireLength()` requires the SAME length twice in a
|
|
304
330
|
row (a disagreement logs a warning and restarts the count from the new value, never averages)
|
|
305
331
|
before treating it as confirmed, in case a firmware turns out to add a different number of
|
|
306
|
-
undocumented bytes on different occasions.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
332
|
+
undocumented bytes on different occasions.
|
|
333
|
+
|
|
334
|
+
That two-in-a-row rule has exactly one shortcut, applied uniformly regardless of where a starting
|
|
335
|
+
value came from: `IdmProtocol#seedMeasuredWireLength()` seeds a length as an unconfirmed CANDIDATE
|
|
336
|
+
rather than an already-trusted value, and a candidate counts as the FIRST of the two required
|
|
337
|
+
measurements, so only one further fresh, matching read is needed to confirm it - never an instant
|
|
338
|
+
grant. Two things get seeded this way: a value restored from a previous adapter run (see below), and
|
|
339
|
+
- as of this release - an explicit, hand-verified `wireLength` in a firmware's data block definition
|
|
340
|
+
(see [`lib/datablocks/README.md`](lib/datablocks/README.md)), seeded by `IdmProtocol#initialize()`
|
|
341
|
+
for every block that declares one. Previously a declared `wireLength` was trusted outright and
|
|
342
|
+
forever, with no live check at all; now it still costs nothing extra in the normal case (the one
|
|
343
|
+
confirming read happens as part of the startup phase every firmware already goes through), but every
|
|
344
|
+
firmware - built-in lengths or not - gets the same live safety net, and a firmware update or
|
|
345
|
+
undocumented quirk that changes a block's actual length no longer goes undetected forever.
|
|
346
|
+
|
|
347
|
+
Every outcome is logged (routine progress at `info`, a disagreement at `warn`), and the moment a
|
|
348
|
+
group's every block becomes trusted this way, that's logged too and multi-block requests for it
|
|
349
|
+
start from its very next turn. Each newly CONFIRMED length is also persisted to the
|
|
350
|
+
`info.measuredWireLengths` ioBroker state (`onWireLengthLearned` hook, wired up in `main.js`) as
|
|
351
|
+
`{"<firmware version>": {"<block id>": <byte length>}}`, and restored on the next adapter start
|
|
352
|
+
(`loadMeasuredWireLengths()`, before the session starts) via `seedMeasuredWireLength()` - the same
|
|
353
|
+
seed-then-confirm path described above, so it too needs one fresh matching measurement before
|
|
354
|
+
relying on it again, in case some heat pump setting turns out to affect a block's length after all.
|
|
355
|
+
|
|
356
|
+
A length - seeded or fully confirmed - can also be forgotten outright: see the "lost sync" handling
|
|
357
|
+
in the "Multi-block requests, per group" section above (`IdmProtocol#forgetMeasuredWireLengths()`),
|
|
358
|
+
which forces a whole group back through this same measurement path from scratch after a multi-block
|
|
359
|
+
reply couldn't be parsed. See the "wireLength learning" tests in `lib/idm-session.test.js` (and
|
|
360
|
+
`idm-protocol.test.js` for the underlying confirm/mismatch/seed/forget mechanics).
|
|
317
361
|
|
|
318
362
|
### Overriding the data blocks without an adapter update
|
|
319
363
|
The instance setting **"Custom data blocks directory"** (`native.dataBlocksDir`) can point at a directory of your own such files. Each file's `"version"` field is matched against the version string the heat pump reports after connecting - a match REPLACES that version's bundled definition entirely (it is not merged field-by-field), useful for adding min/max limits you have verified for your own installation, fixing a field, or adding a not-yet-supported control version, all without reinstalling or upgrading the adapter. Versions with no matching (and valid) custom file keep using their bundled definition. A file that fails validation, or two files claiming the same version, are both rejected with a warning in the adapter's log - the bundled definition (if any) is kept in that case.
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "idm-multitalent_002",
|
|
4
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.3",
|
|
5
5
|
"news": {
|
|
6
|
+
"2.1.3": {
|
|
7
|
+
"en": "every data block's wire length now always needs a fresh confirming read from the actual connected hardware before multi-block requests trust it - even a hand-verified value built into a firmware's data block definition, which used to be trusted outright and forever with no live check at all. This costs nothing extra in the normal case (the confirming read happens during the startup phase every firmware already goes through), and means that if a multi-block reply is ever misread (an unrecognized block id, a block shorter than expected, or leftover trailing bytes), the adapter now forgets that whole group's wire lengths and re-confirms them from scratch instead of continuing to trust a value that just proved wrong. Also: sensor and settings turns now alternate strictly 1:1 instead of 2:1 (a turn has collected its whole group before yielding since 2.1.2, so the old sensor bias no longer bought it anything, and settings now gets its fair share); the frame-length safety cap is reduced from 2048 to 1024 hex characters (still more than double the largest real combined reply seen so far); and new info-level log lines report, once per connection, how many blocks in each group still need a confirming measurement",
|
|
8
|
+
"de": "die Leitungslänge jedes Datenblocks benötigt jetzt immer eine erneute Bestätigung durch einen frischen Messwert von der tatsächlich angeschlossenen Hardware, bevor multi-block-Anfragen ihr vertrauen - selbst ein händisch verifizierter Wert in der Datenblock-Definition einer Firmware, der bisher sofort und dauerhaft ohne jede Live-Prüfung vertraut wurde. Im Normalfall kostet das nichts zusätzlich (die Bestätigung erfolgt während der Startphase, die jede Firmware ohnehin durchläuft), und es bedeutet: wird eine multi-block-Antwort einmal falsch gelesen (eine unbekannte Blockkennung, ein kürzerer Block als erwartet, oder übrig gebliebene Bytes am Ende), vergisst der Adapter jetzt alle Leitungslängen dieser ganzen Gruppe und bestätigt sie von Grund auf neu, statt weiter einem Wert zu vertrauen, der sich gerade als falsch erwiesen hat. Außerdem: Sensor- und Settings-Durchläufe wechseln sich jetzt strikt im Verhältnis 1:1 ab statt 2:1 (ein Durchlauf sammelt seit 2.1.2 immer die ganze Gruppe, bevor er abgibt, sodass die alte Sensor-Bevorzugung nichts mehr brachte und Settings jetzt seinen fairen Anteil bekommt); die Sicherheitsgrenze für die Rahmenlänge wurde von 2048 auf 1024 Hex-Zeichen reduziert (immer noch mehr als doppelt so viel wie die bisher größte real beobachtete kombinierte Antwort); und neue Log-Zeilen auf Info-Ebene melden einmal pro Verbindung, wie viele Blöcke in jeder Gruppe noch eine bestätigende Messung benötigen",
|
|
9
|
+
"ru": "теперь длина блока данных на линии всегда требует свежего подтверждающего измерения от реально подключённого оборудования, прежде чем multi-block-запросы начнут ей доверять - даже вручную проверенное значение, встроенное в описание блоков данных прошивки, которое раньше доверялось сразу и навсегда без какой-либо живой проверки. В обычном случае это не стоит ничего лишнего (подтверждающее чтение происходит во время начальной фазы, которую и так проходит каждая прошивка), и означает, что если multi-block-ответ когда-либо будет прочитан неверно (нераспознанный идентификатор блока, блок короче ожидаемого или оставшиеся лишние байты), адаптер теперь забывает все длины блоков этой группы и подтверждает их заново с нуля, вместо того чтобы продолжать доверять значению, которое только что оказалось неверным. Также: такты сенсоров и настроек теперь строго чередуются 1:1 вместо 2:1 (начиная с 2.1.2 такт всегда собирает всю группу целиком, прежде чем передать управление, поэтому старое преимущество сенсоров больше ничего не давало, и настройки теперь получают свою справедливую долю тактов); защитный предел длины кадра снижен с 2048 до 1024 шестнадцатеричных символов (всё ещё более чем вдвое больше самого крупного реального объединённого ответа, встреченного до сих пор); и новые строки журнала уровня info сообщают, один раз за соединение, сколько блоков в каждой группе всё ещё нуждаются в подтверждающем измерении",
|
|
10
|
+
"pt": "o comprimento de cada bloco de dados agora sempre precisa de uma nova leitura de confirmação a partir do hardware realmente conectado antes que as requisições multi-block confiem nele - mesmo um valor verificado manualmente e embutido na definição de blocos de dados de um firmware, que antes era confiado de imediato e para sempre, sem qualquer verificação ao vivo. Isso não custa nada extra no caso normal (a leitura de confirmação ocorre durante a fase inicial que todo firmware já passa), e significa que, se uma resposta multi-block for lida incorretamente (um id de bloco não reconhecido, um bloco mais curto que o esperado, ou bytes sobrando no final), o adaptador agora esquece os comprimentos de todo aquele grupo e os reconfirma do zero, em vez de continuar confiando em um valor que acabou de se mostrar errado. Além disso: os turnos de sensores e configurações agora se alternam estritamente 1:1 em vez de 2:1 (desde a 2.1.2 um turno sempre coleta o grupo inteiro antes de passar adiante, então o antigo favorecimento dos sensores deixou de trazer vantagem, e as configurações agora recebem sua parte justa); o limite de segurança do comprimento do quadro foi reduzido de 2048 para 1024 caracteres hexadecimais (ainda mais que o dobro da maior resposta combinada real já observada); e novas linhas de log de nível info relatam, uma vez por conexão, quantos blocos em cada grupo ainda precisam de uma medição de confirmação",
|
|
11
|
+
"nl": "de lengte van elk datablok heeft nu altijd een nieuwe bevestigende meting van de daadwerkelijk aangesloten hardware nodig voordat multi-block-verzoeken deze vertrouwen - zelfs een handmatig geverifieerde waarde die in de datablok-definitie van een firmware is ingebouwd, die voorheen meteen en voorgoed werd vertrouwd zonder enige live controle. In het normale geval kost dit niets extra (de bevestigende meting vindt plaats tijdens de opstartfase die elke firmware toch al doorloopt), en het betekent dat als een multi-block-antwoord ooit verkeerd wordt gelezen (een onbekende blok-id, een blok korter dan verwacht, of overgebleven bytes aan het eind), de adapter nu alle lengtes van die hele groep vergeet en ze opnieuw vanaf nul bevestigt, in plaats van te blijven vertrouwen op een waarde die net onjuist bleek. Ook: sensor- en instellingenbeurten wisselen elkaar nu strikt 1:1 af in plaats van 2:1 (sinds 2.1.2 verzamelt een beurt altijd de hele groep voordat hij wordt doorgegeven, dus de oude sensor-voorkeur leverde niets meer op, en instellingen krijgen nu hun eerlijke aandeel); de veiligheidsgrens voor de framelengte is verlaagd van 2048 naar 1024 hex-tekens (nog steeds meer dan het dubbele van het grootste echte gecombineerde antwoord dat tot nu toe is gezien); en nieuwe logregels op info-niveau melden, eenmaal per verbinding, hoeveel blokken in elke groep nog een bevestigende meting nodig hebben",
|
|
12
|
+
"fr": "la longueur de chaque bloc de données nécessite désormais toujours une nouvelle lecture de confirmation depuis le matériel réellement connecté avant que les requêtes multi-block ne lui fassent confiance - même une valeur vérifiée manuellement et intégrée à la définition des blocs de données d'un firmware, qui était auparavant approuvée d'emblée et pour toujours, sans aucune vérification en direct. Cela ne coûte rien de plus dans le cas normal (la lecture de confirmation a lieu pendant la phase de démarrage que chaque firmware traverse de toute façon), et cela signifie que si une réponse multi-block est un jour mal lue (un identifiant de bloc non reconnu, un bloc plus court que prévu, ou des octets restants à la fin), l'adaptateur oublie désormais toutes les longueurs de ce groupe entier et les reconfirme depuis le début, au lieu de continuer à faire confiance à une valeur qui vient de se révéler fausse. Par ailleurs : les tours des capteurs et des réglages alternent désormais strictement 1:1 au lieu de 2:1 (depuis la 2.1.2, un tour collecte toujours le groupe entier avant de céder la main, si bien que l'ancien avantage des capteurs n'apportait plus rien, et les réglages reçoivent désormais leur juste part) ; la limite de sécurité de longueur de trame est réduite de 2048 à 1024 caractères hexadécimaux (toujours plus du double de la plus grande réponse combinée réelle observée jusqu'ici) ; et de nouvelles lignes de journal de niveau info indiquent, une fois par connexion, combien de blocs de chaque groupe ont encore besoin d'une mesure de confirmation",
|
|
13
|
+
"it": "la lunghezza di ogni blocco di dati richiede ora sempre una nuova lettura di conferma dall'hardware effettivamente collegato prima che le richieste multi-block se ne fidino - anche un valore verificato manualmente e integrato nella definizione dei blocchi di dati di un firmware, che prima veniva considerato attendibile subito e per sempre, senza alcun controllo dal vivo. Nel caso normale questo non costa nulla in più (la lettura di conferma avviene durante la fase di avvio che ogni firmware attraversa comunque), e significa che se una risposta multi-block viene letta male (un id di blocco non riconosciuto, un blocco più corto del previsto, o byte residui alla fine), l'adattatore ora dimentica tutte le lunghezze di quell'intero gruppo e le riconferma da zero, invece di continuare a fidarsi di un valore che si è appena rivelato sbagliato. Inoltre: i turni di sensori e impostazioni ora si alternano rigorosamente 1:1 invece che 2:1 (dalla 2.1.2 un turno raccoglie sempre l'intero gruppo prima di cedere il turno, quindi il vecchio vantaggio dei sensori non portava più nulla, e le impostazioni ora ricevono la loro giusta quota); il limite di sicurezza sulla lunghezza del frame è ridotto da 2048 a 1024 caratteri esadecimali (ancora più del doppio della più grande risposta combinata reale osservata finora); e nuove righe di log a livello info riportano, una volta per connessione, quanti blocchi in ciascun gruppo necessitano ancora di una misurazione di conferma",
|
|
14
|
+
"es": "la longitud de cada bloque de datos ahora siempre necesita una nueva lectura de confirmación desde el hardware realmente conectado antes de que las solicitudes multi-block confíen en ella - incluso un valor verificado manualmente e incorporado en la definición de bloques de datos de un firmware, que antes se confiaba de inmediato y para siempre, sin ninguna comprobación en vivo. Esto no cuesta nada extra en el caso normal (la lectura de confirmación ocurre durante la fase de inicio por la que ya pasa cada firmware), y significa que si una respuesta multi-block se lee mal alguna vez (un id de bloque no reconocido, un bloque más corto de lo esperado, o bytes sobrantes al final), el adaptador ahora olvida todas las longitudes de ese grupo completo y las reconfirma desde cero, en lugar de seguir confiando en un valor que acaba de demostrar ser incorrecto. Además: los turnos de sensores y configuración ahora se alternan estrictamente 1:1 en lugar de 2:1 (desde la 2.1.2 un turno siempre recopila todo el grupo antes de ceder el turno, por lo que el antiguo favoritismo hacia los sensores ya no aportaba nada, y la configuración ahora recibe su parte justa); el límite de seguridad de longitud de trama se reduce de 2048 a 1024 caracteres hexadecimales (todavía más del doble de la mayor respuesta combinada real vista hasta ahora); y nuevas líneas de registro de nivel info informan, una vez por conexión, cuántos bloques de cada grupo aún necesitan una medición de confirmación",
|
|
15
|
+
"pl": "długość każdego bloku danych zawsze wymaga teraz świeżego potwierdzającego odczytu z faktycznie podłączonego sprzętu, zanim zaufają jej żądania multi-block - nawet ręcznie zweryfikowana wartość wbudowana w definicję bloków danych danej wersji firmware, której wcześniej ufano od razu i na zawsze, bez żadnej weryfikacji na żywo. W normalnym przypadku nie kosztuje to nic dodatkowego (potwierdzający odczyt następuje podczas fazy startowej, którą i tak przechodzi każdy firmware), i oznacza to, że jeśli odpowiedź multi-block zostanie kiedyś błędnie odczytana (nierozpoznany identyfikator bloku, blok krótszy niż oczekiwano lub pozostałe bajty na końcu), adapter teraz zapomina wszystkie długości całej tej grupy i potwierdza je od nowa, zamiast dalej ufać wartości, która właśnie okazała się błędna. Ponadto: tury czujników i ustawień na przemian ściśle 1:1 zamiast 2:1 (od wersji 2.1.2 tura zawsze zbiera całą grupę przed oddaniem tury, więc stare uprzywilejowanie czujników nic już nie dawało, a ustawienia dostają teraz swoją sprawiedliwą część); limit bezpieczeństwa długości ramki zmniejszono z 2048 do 1024 znaków szesnastkowych (nadal ponad dwukrotnie więcej niż największa dotychczas zaobserwowana rzeczywista połączona odpowiedź); a nowe wpisy dziennika na poziomie info raportują, raz na połączenie, ile bloków w każdej grupie wciąż wymaga potwierdzającego pomiaru",
|
|
16
|
+
"zh-cn": "现在,每个数据区块的线路长度在多区块请求信任它之前,始终需要来自实际连接硬件的一次新的确认读取——即使是固件数据区块定义中内置的、经过人工验证的数值,以前也是被立即且永久信任、完全没有任何实时校验的。在正常情况下这不会带来额外开销(确认读取发生在每个固件本来就会经历的启动阶段),并且这意味着:如果某次多区块应答被误读(出现无法识别的区块标识、区块比预期短,或末尾残留多余字节),适配器现在会忘记整个分组的所有线路长度,并从头重新确认,而不是继续信任一个刚刚被证明错误的数值。此外:传感器与设置的轮询回合现在严格按 1:1 交替,而不再是 2:1(自 2.1.2 起,一个回合总会先采集完整个分组再让出,因此旧有的传感器优先已不再带来任何好处,设置现在也能获得公平的回合份额);帧长度安全上限从 2048 个十六进制字符降低到 1024 个(仍是迄今观察到的最大实际合并应答的两倍多);新增的 info 级别日志会在每次连接时报告每个分组中还有多少区块需要确认测量",
|
|
17
|
+
"uk": "довжина кожного блоку даних тепер завжди потребує свіжого підтверджувального читання з реально підключеного обладнання, перш ніж multi-block-запити почнуть їй довіряти - навіть вручну перевіреному значенню, вбудованому в опис блоків даних прошивки, якому раніше довіряли одразу й назавжди без жодної живої перевірки. У звичайному випадку це не коштує нічого додаткового (підтверджувальне читання відбувається під час стартової фази, яку й так проходить кожна прошивка), і означає, що якщо multi-block-відповідь колись буде прочитано неправильно (нерозпізнаний ідентифікатор блоку, блок коротший за очікуваний, або зайві байти в кінці), адаптер тепер забуває всі довжини цієї цілої групи і підтверджує їх заново з нуля, замість того щоб і далі довіряти значенню, яке щойно виявилося хибним. Також: такти датчиків і налаштувань тепер строго чергуються 1:1 замість 2:1 (від версії 2.1.2 такт завжди збирає всю групу цілком, перш ніж передати чергу, тому стара перевага датчиків більше нічого не давала, і налаштування тепер отримують свою справедливу частку); захисну межу довжини кадру знижено з 2048 до 1024 шістнадцяткових символів (усе ще більш ніж удвічі більше за найбільшу реальну об'єднану відповідь, яка траплялася досі); а нові рядки журналу рівня info повідомляють, один раз на з'єднання, скільки блоків у кожній групі ще потребують підтверджувального вимірювання"
|
|
18
|
+
},
|
|
6
19
|
"2.1.2": {
|
|
7
20
|
"en": "fix: a multi-block group's turn (sensor or settings) used to give up after exactly one request/reply attempt and hand off to the other group even if it hadn't found every block yet - on real hardware, where settings often needs several re-asks to fully arrive, this made settings collection nearly as slow as before 2.0.0 despite sensor freshness having improved. A turn now collects the whole group - re-asking with an adaptive backoff, exactly like the original 2.0.0 mechanism - before handing off, extended to sensor too",
|
|
8
21
|
"de": "Fehlerbehebung: der Durchlauf einer multi-block-fähigen Gruppe (Sensor oder Settings) brach bisher nach genau einem Anfrage-/Antwortversuch ab und übergab an die andere Gruppe, selbst wenn noch nicht alle Blöcke gefunden waren - auf echter Hardware, wo Settings oft mehrere Nachfragen benötigen, bis alles vollständig da ist, machte das die Settings-Sammlung fast so langsam wie vor 2.0.0, obwohl sich die Aktualität der Sensordaten verbessert hatte. Ein Durchlauf sammelt jetzt die ganze Gruppe - mit adaptivem Backoff nachfragend, genau wie beim ursprünglichen 2.0.0-Mechanismus, jetzt auch für Sensor - bevor übergeben wird",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "wiersz logu pełnego cyklu wypisuje teraz aktualne opóźnienie treści każdego bloku, a adaptacyjne opóźnienie na blok jest teraz zmniejszane coraz drobniejszymi krokami (aż do 1ms) zamiast zawsze o stałe 100ms",
|
|
81
94
|
"zh-cn": "完整周期日志行现在会列出每个数据块当前的内容延迟,自适应的按块延迟现在以越来越精细的步长(最低至1毫秒)递减,而不再总是固定的100毫秒",
|
|
82
95
|
"uk": "рядок журналу повного циклу тепер показує поточну затримку вмісту кожного блока, а адаптивна затримка на блок тепер зменшується дедалі дрібнішими кроками (аж до 1мс) замість фіксованих 100мс"
|
|
83
|
-
},
|
|
84
|
-
"1.3.8": {
|
|
85
|
-
"en": "remove the frequent per-sweep 'completed one full poll cycle' log line entirely (fired every ~14s) - only the much less frequent full-coverage cycle line remains, confirmed correct against real production logs",
|
|
86
|
-
"de": "die häufige Log-Zeile 'completed one full poll cycle' (feuerte alle ~14s) komplett entfernt - nur die deutlich seltenere Vollzyklus-Zeile bleibt, deren Korrektheit anhand echter Produktions-Logs bestätigt wurde",
|
|
87
|
-
"ru": "часто встречающаяся строка журнала 'completed one full poll cycle' (срабатывала каждые ~14 с) полностью удалена - осталась только гораздо более редкая строка полного цикла, корректность которой подтверждена по реальным журналам",
|
|
88
|
-
"pt": "removida por completo a linha de log frequente 'completed one full poll cycle' (disparava a cada ~14s) - resta apenas a linha do ciclo completo, bem mais rara, cuja correção foi confirmada em logs reais de produção",
|
|
89
|
-
"nl": "de veelvoorkomende logregel 'completed one full poll cycle' (vuurde elke ~14s af) volledig verwijderd - alleen de veel minder frequente full-coverage-cyclusregel blijft over, waarvan de juistheid is bevestigd aan de hand van echte productielogs",
|
|
90
|
-
"fr": "suppression complète de la ligne de log fréquente 'completed one full poll cycle' (se déclenchait toutes les ~14s) - seule la ligne de cycle complet, bien plus rare, reste, sa justesse ayant été confirmée sur de vrais logs de production",
|
|
91
|
-
"it": "rimossa completamente la frequente riga di log 'completed one full poll cycle' (scattava ogni ~14s) - resta solo la riga del ciclo completo, molto meno frequente, la cui correttezza è stata confermata su log di produzione reali",
|
|
92
|
-
"es": "eliminada por completo la línea de registro frecuente 'completed one full poll cycle' (se disparaba cada ~14s) - solo queda la línea del ciclo completo, mucho menos frecuente, cuya corrección se confirmó con registros reales de producción",
|
|
93
|
-
"pl": "całkowicie usunięto częsty wiersz logu 'completed one full poll cycle' (uruchamiał się co ~14s) - pozostał tylko znacznie rzadszy wiersz pełnego cyklu, którego poprawność potwierdzono na rzeczywistych logach produkcyjnych",
|
|
94
|
-
"zh-cn": "彻底移除了频繁出现的 'completed one full poll cycle' 日志行(约每 14 秒触发一次)- 仅保留频率低得多的完整周期日志行,其正确性已通过真实生产日志验证",
|
|
95
|
-
"uk": "повністю видалено частий рядок журналу 'completed one full poll cycle' (спрацьовував що ~14с) - залишився лише значно рідший рядок повного циклу, коректність якого підтверджено на реальних лог-файлах"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|
package/lib/datablocks/README.md
CHANGED
|
@@ -46,12 +46,18 @@ definition (if any) is kept in that case rather than guessing which one to use.
|
|
|
46
46
|
wire (which can be a few bytes longer than its documented fields account for - the extra
|
|
47
47
|
bytes are simply ignored, they just have to be skipped correctly). **Only add this once you
|
|
48
48
|
have verified it against real hardware traffic.** An explicit, hand-verified `wireLength`
|
|
49
|
-
here
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
here still isn't trusted outright, though - the adapter always confirms it once against the
|
|
50
|
+
actual connected hardware before relying on it (one matching live read is enough, since a
|
|
51
|
+
verified value already starts a step ahead of a block with none); without one, the adapter
|
|
52
|
+
measures and confirms it for itself entirely from ordinary traffic at runtime instead, needing
|
|
53
|
+
two consecutive matching reads since it has nothing to start from (see the main README's
|
|
54
|
+
"wireLength learning" section) - either way, once every block in a firmware's sensor or
|
|
55
|
+
settings group has a confirmed length, that whole group is requested together in one combined
|
|
56
|
+
multi-block request instead of one block at a time, and if a combined reply ever turns out to
|
|
57
|
+
be misread, the whole group's confirmed lengths (this one included) are forgotten and
|
|
58
|
+
re-confirmed from scratch rather than continuing to trust a value that just proved wrong.
|
|
59
|
+
Leaving this out just means it takes one extra confirming read to kick in after every restart,
|
|
60
|
+
not that it never will.
|
|
55
61
|
* `definition` - array of fields:
|
|
56
62
|
* `statename` - the ioBroker state created for this field (empty for padding/unused positions)
|
|
57
63
|
* `field` - internal field name (only used for debugging/comments)
|
package/lib/idm-protocol.js
CHANGED
|
@@ -5,12 +5,15 @@ const idm_datablocks = require('./idm_datablocks');
|
|
|
5
5
|
const idm_utils = require('./idm-utils');
|
|
6
6
|
|
|
7
7
|
// Generous upper bound on how many characters add_to_packet() will accumulate between a SOH
|
|
8
|
-
// and the next ETX/EOT before giving up on the frame.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
8
|
+
// and the next ETX/EOT before giving up on the frame. This is purely defensive: without it, a
|
|
9
|
+
// stream that never sends a terminating ETX (line noise on the RS422/TCP path, a misbehaving
|
|
10
|
+
// serial-to-TCP converter, ...) would grow received_data_packet/receive_chksum forever, since
|
|
11
|
+
// only a genuine SOH/ETX/EOT byte - never plain length - is used to trigger a state change or
|
|
12
|
+
// error. 1024 hex characters (~512 bytes) comfortably covers the largest real combined
|
|
13
|
+
// multi-block reply seen so far (S_H726100's 7-block settings group, ~442 hex characters) with
|
|
14
|
+
// more than double headroom, while still catching runaway noise well before it could be mistaken
|
|
15
|
+
// for a plausible heat-pump-control reply.
|
|
16
|
+
const MAX_FRAME_LENGTH = 1024;
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Protocol/data-block state for ONE heat pump connection.
|
|
@@ -43,17 +46,24 @@ class IdmProtocol {
|
|
|
43
46
|
this.remaining_data = '';
|
|
44
47
|
|
|
45
48
|
// version -> Map(block -> {length, confirmed}) - see recordMeasuredWireLength() and
|
|
46
|
-
// getVerifiedBlockWireLength().
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
49
|
+
// getVerifiedBlockWireLength(). Every block goes through this, one way or another, before
|
|
50
|
+
// it's trusted for multi-block requests (see firmwareSupportsMultiBlockRequestsForBlocks()):
|
|
51
|
+
// seeded with a starting candidate - a JSON-declared "wireLength" (initialize()) or a
|
|
52
|
+
// value restored from a previous run (main.js, via seedMeasuredWireLength()) - or, with
|
|
53
|
+
// no seed at all, learned entirely from scratch via two consecutive live measurements.
|
|
54
|
+
// Either way it's real traffic (see IdmSession's wireLength-learning hook in
|
|
55
|
+
// receive_data()) that ultimately confirms a length, never a maintainer's or a previous
|
|
56
|
+
// run's say-so alone.
|
|
51
57
|
this.measuredWireLengths = new Map();
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/**
|
|
55
61
|
* Loads the hardware data block definitions (see idm_datablocks.js) and populates the
|
|
56
|
-
* lookup Maps used by the rest of this class, one entry per firmware version.
|
|
62
|
+
* lookup Maps used by the rest of this class, one entry per firmware version. Also seeds an
|
|
63
|
+
* unconfirmed measuredWireLengths CANDIDATE (see seedMeasuredWireLength()) for every block
|
|
64
|
+
* that has a JSON-declared "wireLength" - a hand-verified starting guess, not an instant
|
|
65
|
+
* grant (see getVerifiedBlockWireLength()'s comment for why every block, JSON-declared or
|
|
66
|
+
* not, needs at least one live confirming measurement before multi-block requests trust it).
|
|
57
67
|
* @param {string} [customDir] absolute path to a directory of custom per-version data
|
|
58
68
|
* blocks files, e.g. from the adapter's "dataBlocksDir" setting - a version with no
|
|
59
69
|
* matching (and valid) file there keeps using its bundled definition
|
|
@@ -71,10 +81,13 @@ class IdmProtocol {
|
|
|
71
81
|
this.speed.clear();
|
|
72
82
|
// Also reset here: main.js always calls initialize() (constructor, then again in
|
|
73
83
|
// onReady() with the configured customDir) BEFORE ever seeding a measurement persisted
|
|
74
|
-
// from a previous run (see seedMeasuredWireLength()), so
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
84
|
+
// from a previous run (see seedMeasuredWireLength()), so the JSON seeding below is always
|
|
85
|
+
// what a real run starts from, with main.js's persisted-state seeding layered on top of
|
|
86
|
+
// it afterwards (overwriting a block's candidate where a persisted one exists - both are
|
|
87
|
+
// just unconfirmed candidates either way, so it makes no difference which one a block
|
|
88
|
+
// happens to start from). This clear() also keeps a test suite that reuses one
|
|
89
|
+
// IdmProtocol instance across several initialize() calls (e.g. one per `it`, via a shared
|
|
90
|
+
// beforeEach) from leaking measurements learned in one test into the next.
|
|
78
91
|
this.measuredWireLengths.clear();
|
|
79
92
|
|
|
80
93
|
for (const [version, data] of byVersion) {
|
|
@@ -83,6 +96,11 @@ class IdmProtocol {
|
|
|
83
96
|
this.sensorDataBlocks.set(version, data.sensorBlocks);
|
|
84
97
|
this.settingsDataBlocks.set(version, data.settingsBlocks);
|
|
85
98
|
this.speed.set(version, data.speed);
|
|
99
|
+
for (const block of data.data_blocks) {
|
|
100
|
+
if (typeof block.wireLength === 'number') {
|
|
101
|
+
this.seedMeasuredWireLength(version, block.block_number, block.wireLength);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
106
|
|
|
@@ -195,10 +213,14 @@ class IdmProtocol {
|
|
|
195
213
|
/**
|
|
196
214
|
* The TRUSTED wire length for `block`/`version`, for anything that needs to walk a
|
|
197
215
|
* multi-block reply's byte boundaries (see parse_multi_block_reply()) rather than just
|
|
198
|
-
* tolerate extra trailing bytes the way a single-block reply's own framing already does:
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
216
|
+
* tolerate extra trailing bytes the way a single-block reply's own framing already does: a
|
|
217
|
+
* length CONFIRMED via recordMeasuredWireLength() - either from two consecutive live
|
|
218
|
+
* measurements, or from just one live measurement matching a SEEDED candidate (a
|
|
219
|
+
* JSON-declared "wireLength", seeded by initialize(); or a value restored from a previous
|
|
220
|
+
* adapter run, seeded by main.js via seedMeasuredWireLength()). A JSON-declared "wireLength"
|
|
221
|
+
* is deliberately NOT trusted outright here, hand-verified or not: it's still only a
|
|
222
|
+
* candidate until this run's own traffic confirms it once, exactly like a restored value -
|
|
223
|
+
* see seedMeasuredWireLength()'s comment for why. Deliberately never falls back to the
|
|
202
224
|
* documented field-sum getBlockWireLength() does for everything else - that fallback is a
|
|
203
225
|
* plausible guess (blocks are known to carry undocumented trailing bytes beyond their listed
|
|
204
226
|
* fields), and a wrong guess here wouldn't just mis-parse one block's own fields, it would
|
|
@@ -208,9 +230,6 @@ class IdmProtocol {
|
|
|
208
230
|
* @returns {number | null}
|
|
209
231
|
*/
|
|
210
232
|
getVerifiedBlockWireLength(version, block) {
|
|
211
|
-
const definitions = this.dataDefinitions.get(version);
|
|
212
|
-
const entry = definitions ? definitions.data_blocks.find((b) => b.block_number === block) : undefined;
|
|
213
|
-
if (entry && typeof entry.wireLength === 'number') return entry.wireLength;
|
|
214
233
|
const measured = version ? this.measuredWireLengths.get(version)?.get(block) : undefined;
|
|
215
234
|
if (measured && measured.confirmed) return measured.length;
|
|
216
235
|
return null;
|
|
@@ -219,21 +238,22 @@ class IdmProtocol {
|
|
|
219
238
|
/**
|
|
220
239
|
* Records one single-block reply's ACTUAL wire length for `block`/`version`, learned
|
|
221
240
|
* passively from real traffic (see IdmSession's wireLength-learning hook in receive_data(),
|
|
222
|
-
* the only caller)
|
|
223
|
-
*
|
|
224
|
-
* data blocks file or an earlier confirmed measurement.
|
|
241
|
+
* the only caller) - a no-op once the block already has a trusted length (see
|
|
242
|
+
* getVerifiedBlockWireLength()).
|
|
225
243
|
*
|
|
226
244
|
* A single-block reply is fully checksum-verified before this is ever called (see
|
|
227
245
|
* add_to_packet()), so a CORRUPTED length can't slip in that way - what this guards against
|
|
228
246
|
* instead is something we simply don't know either way, never having tested it: whether a
|
|
229
247
|
* given firmware could append a different number of undocumented reserved/padding bytes on
|
|
230
|
-
* different occasions
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
248
|
+
* different occasions, or even change its wire format between installations or over time
|
|
249
|
+
* (e.g. a firmware update) despite reporting the same version string. Two CONSECUTIVE
|
|
250
|
+
* matching measurements are required before a length is trusted enough to unlock multi-block
|
|
251
|
+
* requests for its group (see firmwareSupportsMultiBlockRequestsForBlocks()) - a disagreement
|
|
252
|
+
* simply restarts the count from the new value rather than ever averaging or guessing which
|
|
253
|
+
* one was right. A SEEDED candidate (see seedMeasuredWireLength() - a value restored from a
|
|
254
|
+
* previous run, or a JSON-declared "wireLength") counts as the FIRST of those two
|
|
255
|
+
* measurements, not a free pass - every restart re-confirms once before its group is trusted
|
|
256
|
+
* for multi-block again, exactly like a block seen for the very first time ever.
|
|
237
257
|
* @param {string | undefined} version
|
|
238
258
|
* @param {string} block
|
|
239
259
|
* @param {number} length byte length of this reply's payload (after the 01F200+block header)
|
|
@@ -256,18 +276,27 @@ class IdmProtocol {
|
|
|
256
276
|
}
|
|
257
277
|
|
|
258
278
|
/**
|
|
259
|
-
* Seeds `length` as a CANDIDATE measured wireLength for `block`/`version
|
|
260
|
-
*
|
|
261
|
-
*
|
|
279
|
+
* Seeds `length` as a CANDIDATE measured wireLength for `block`/`version`. Two sources call
|
|
280
|
+
* this, both feeding the SAME "needs one fresh confirming measurement" pipeline:
|
|
281
|
+
* - main.js, restoring a value persisted from a PREVIOUS adapter run (an ioBroker state),
|
|
282
|
+
* before the connection is started;
|
|
283
|
+
* - initialize(), for every block with a JSON-declared "wireLength" in its data blocks
|
|
284
|
+
* file - a hand-verified starting guess, not an instant grant (see
|
|
285
|
+
* getVerifiedBlockWireLength()'s comment for why).
|
|
262
286
|
*
|
|
263
287
|
* Deliberately does NOT mark it confirmed outright - every restart re-measures once before
|
|
264
288
|
* trusting it again (see recordMeasuredWireLength(), whose normal "two consecutive matching
|
|
265
289
|
* measurements" rule this feeds into as if the seeded value were the first of those two): we
|
|
266
|
-
* have no hard evidence a control's wire length is fixed forever
|
|
267
|
-
* some heat pump setting,
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
290
|
+
* have no hard evidence a control's wire length is fixed forever - whether because it depends
|
|
291
|
+
* on some heat pump setting, or because a JSON value was estimated/wrong, or a firmware
|
|
292
|
+
* update silently changed it - so treating ANY seed as an unconfirmed candidate, needing
|
|
293
|
+
* exactly one fresh matching measurement THIS run, costs one extra request per block per
|
|
294
|
+
* restart but means every block - freshly seen, previously learned, or hand-verified in the
|
|
295
|
+
* data blocks file - is held to the same standard rather than any one source being trusted
|
|
296
|
+
* blindly forever. Calling this again for a block that already has an entry (confirmed or
|
|
297
|
+
* not) simply replaces it with a fresh unconfirmed candidate - used by
|
|
298
|
+
* forgetMeasuredWireLengths() to force a clean re-measurement after a multi-block parse error
|
|
299
|
+
* suggests a trusted length has gone wrong.
|
|
271
300
|
* @param {string} version
|
|
272
301
|
* @param {string} block
|
|
273
302
|
* @param {number} length
|
|
@@ -277,6 +306,29 @@ class IdmProtocol {
|
|
|
277
306
|
this.measuredWireLengths.get(version).set(block, { length, confirmed: false });
|
|
278
307
|
}
|
|
279
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Discards any measured/seeded wireLength state for `blockIds`/`version` - used when a
|
|
311
|
+
* multi-block reply couldn't be parsed (see IdmSession#handleMultiBlockDataReply()'s `error`
|
|
312
|
+
* branch, the only caller): losing sync mid-reply means one of the group's wireLengths is no
|
|
313
|
+
* longer correct, but not which one, so the whole group is dropped back to square one rather
|
|
314
|
+
* than guessing. This also intentionally discards a JSON-declared "wireLength" seed, not just
|
|
315
|
+
* a live measurement - if the JSON value itself turned out to be wrong (or a firmware update
|
|
316
|
+
* changed it), re-seeding from the very same value on the next connection would just recreate
|
|
317
|
+
* the mismatch, so the affected block(s) instead start completely fresh:
|
|
318
|
+
* firmwareSupportsMultiBlockRequestsForBlocks() now returns false for the group
|
|
319
|
+
* (getVerifiedBlockWireLength() has nothing left to return), so its next turn automatically
|
|
320
|
+
* falls back to the classic one-block-at-a-time round-robin, which re-learns each block from
|
|
321
|
+
* two fresh consecutive measurements like a block seen for the very first time.
|
|
322
|
+
* @param {string | undefined} version
|
|
323
|
+
* @param {string[]} blockIds
|
|
324
|
+
*/
|
|
325
|
+
forgetMeasuredWireLengths(version, blockIds) {
|
|
326
|
+
if (!version) return;
|
|
327
|
+
const perVersion = this.measuredWireLengths.get(version);
|
|
328
|
+
if (!perVersion) return;
|
|
329
|
+
for (const block of blockIds) perVersion.delete(block);
|
|
330
|
+
}
|
|
331
|
+
|
|
280
332
|
/**
|
|
281
333
|
* Whether MULTI-block requests (asking for several data blocks in one 0171 message - see
|
|
282
334
|
* IdmSession's multi-block collector) are safe to use for `blockIds` under `version`: every
|
|
@@ -296,8 +348,10 @@ class IdmProtocol {
|
|
|
296
348
|
|
|
297
349
|
/**
|
|
298
350
|
* Settings-blocks convenience wrapper - see firmwareSupportsMultiBlockRequestsForBlocks().
|
|
299
|
-
*
|
|
300
|
-
*
|
|
351
|
+
* True once every settings block's wireLength has been confirmed via
|
|
352
|
+
* recordMeasuredWireLength() - whether that took one confirming measurement (a JSON-declared
|
|
353
|
+
* "wireLength", e.g. S_H726100, or a value restored from a previous run) or two independent
|
|
354
|
+
* ones from scratch.
|
|
301
355
|
* @param {string | undefined} version
|
|
302
356
|
* @returns {boolean}
|
|
303
357
|
*/
|
package/lib/idm-protocol.test.js
CHANGED
|
@@ -346,8 +346,35 @@ describe('idm-protocol', () => {
|
|
|
346
346
|
const b09 = '0000D80E'; // 4 bytes, exactly matches its documented fields
|
|
347
347
|
const b0A = '000000002D00'; // 6 bytes, exactly matches its documented fields
|
|
348
348
|
|
|
349
|
+
// S_H726100.json's declared wireLength for every one of its data blocks - see
|
|
350
|
+
// confirmAllS_H726100WireLengths() below.
|
|
351
|
+
const S_H726100_WIRE_LENGTHS = { '03': 28, '04': 87, '05': 30, '06': 23, '07': 36, '08': 36, '09': 4, '0A': 6, '0B': 11, '0C': 42, '0D': 14 };
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A JSON-declared "wireLength" is only a SEEDED CANDIDATE now (see
|
|
355
|
+
* getVerifiedBlockWireLength()'s comment), not an instant grant - most of the tests below
|
|
356
|
+
* are about the multi-block REQUEST/REPLY mechanics, not about the seed-then-confirm
|
|
357
|
+
* transition itself (that has its own dedicated tests, in the next describe block), so
|
|
358
|
+
* this confirms every one of idm_p's S_H726100 blocks with a single matching measurement
|
|
359
|
+
* each - exactly what one real confirming read per block would do - to bring it to the
|
|
360
|
+
* same "fully multi-block-capable" state the old, JSON-was-an-instant-bypass design had
|
|
361
|
+
* from the start.
|
|
362
|
+
*/
|
|
363
|
+
function confirmAllS_H726100WireLengths() {
|
|
364
|
+
for (const [block, length] of Object.entries(S_H726100_WIRE_LENGTHS)) {
|
|
365
|
+
idm_p.recordMeasuredWireLength('S_H726100', block, length);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
349
369
|
describe('firmwareSupportsMultiBlockRequests / getBlockWireLength', () => {
|
|
370
|
+
it('a JSON-declared wireLength is a seeded candidate, not an instant grant - the group only qualifies once every block is confirmed', () => {
|
|
371
|
+
expect(idm_p.firmwareSupportsMultiBlockRequests('S_H726100'), 'not yet - nothing confirmed').to.be.false;
|
|
372
|
+
confirmAllS_H726100WireLengths();
|
|
373
|
+
expect(idm_p.firmwareSupportsMultiBlockRequests('S_H726100'), 'now - every settings block confirmed').to.be.true;
|
|
374
|
+
});
|
|
375
|
+
|
|
350
376
|
it('is true only for S_H726100 - the only firmware with a verified wireLength for every settings block', () => {
|
|
377
|
+
confirmAllS_H726100WireLengths();
|
|
351
378
|
expect(idm_p.firmwareSupportsMultiBlockRequests('S_H726100')).to.be.true;
|
|
352
379
|
for (const version of versions.filter((v) => v !== 'S_H726100')) {
|
|
353
380
|
expect(idm_p.firmwareSupportsMultiBlockRequests(version), version).to.be.false;
|
|
@@ -383,9 +410,17 @@ describe('idm-protocol', () => {
|
|
|
383
410
|
learning.initialize();
|
|
384
411
|
});
|
|
385
412
|
|
|
386
|
-
it('
|
|
413
|
+
it('a JSON-declared wireLength is only a seeded candidate after initialize() - needs one fresh confirming measurement, like any other seed', () => {
|
|
414
|
+
// initialize() (in the outer beforeEach) already seeded '03' from S_H726100.json's
|
|
415
|
+
// declared wireLength (28) - but seeding alone is never enough, see
|
|
416
|
+
// getVerifiedBlockWireLength()'s comment.
|
|
417
|
+
expect(learning.getVerifiedBlockWireLength('S_H726100', '03')).to.be.null;
|
|
418
|
+
// one fresh measurement matching the seed is enough to confirm it (same rule as
|
|
419
|
+
// seedMeasuredWireLength() restoring a persisted value - see the dedicated test below).
|
|
420
|
+
expect(learning.recordMeasuredWireLength('S_H726100', '03', 28)).to.deep.equal({ status: 'confirmed', length: 28 });
|
|
387
421
|
expect(learning.getVerifiedBlockWireLength('S_H726100', '03')).to.equal(28);
|
|
388
|
-
//
|
|
422
|
+
// now that it's confirmed, a further call is a no-op - it doesn't touch the stored
|
|
423
|
+
// length (still 28 below) even though it echoes back whatever was passed in (999)
|
|
389
424
|
expect(learning.recordMeasuredWireLength('S_H726100', '03', 999)).to.deep.equal({ status: 'already-verified', length: 999 });
|
|
390
425
|
expect(learning.getVerifiedBlockWireLength('S_H726100', '03')).to.equal(28); // unchanged
|
|
391
426
|
});
|
|
@@ -447,6 +482,61 @@ describe('idm-protocol', () => {
|
|
|
447
482
|
expect(learning.recordMeasuredWireLength('idm701100', '03', 41)).to.deep.equal({ status: 'confirmed', length: 41 });
|
|
448
483
|
expect(learning.getVerifiedBlockWireLength('idm701100', '03')).to.equal(41);
|
|
449
484
|
});
|
|
485
|
+
|
|
486
|
+
describe('forgetMeasuredWireLengths (see IdmSession#handleMultiBlockDataReply()\'s parse-error branch, the only caller)', () => {
|
|
487
|
+
it('discards a CONFIRMED length, forcing it all the way back through fresh measurement next time', () => {
|
|
488
|
+
learning.recordMeasuredWireLength('idm701100', '03', 41);
|
|
489
|
+
learning.recordMeasuredWireLength('idm701100', '03', 41); // confirmed
|
|
490
|
+
expect(learning.getVerifiedBlockWireLength('idm701100', '03')).to.equal(41);
|
|
491
|
+
|
|
492
|
+
learning.forgetMeasuredWireLengths('idm701100', ['03']);
|
|
493
|
+
|
|
494
|
+
expect(learning.getVerifiedBlockWireLength('idm701100', '03')).to.be.null;
|
|
495
|
+
// back to square one: a single fresh measurement is not enough by itself anymore -
|
|
496
|
+
// it needs two consecutive ones again, exactly like a block never seen before
|
|
497
|
+
expect(learning.recordMeasuredWireLength('idm701100', '03', 41)).to.deep.equal({ status: 'first-measurement', length: 41 });
|
|
498
|
+
expect(learning.getVerifiedBlockWireLength('idm701100', '03')).to.be.null;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('also discards a JSON-declared seed, not just a live measurement - re-seeding from the same value would just recreate the mismatch', () => {
|
|
502
|
+
// A fresh instance, never confirmed - S_H726100's block '03' is seeded
|
|
503
|
+
// (unconfirmed) straight from initialize(), untouched by recordMeasuredWireLength().
|
|
504
|
+
const fresh = new IdmProtocol();
|
|
505
|
+
fresh.initialize();
|
|
506
|
+
expect(fresh.firmwareSupportsMultiBlockRequestsForBlocks('S_H726100', ['03'])).to.be.false;
|
|
507
|
+
|
|
508
|
+
fresh.forgetMeasuredWireLengths('S_H726100', ['03']);
|
|
509
|
+
|
|
510
|
+
// Still nothing trusted - and confirming it now takes a fresh MATCHING
|
|
511
|
+
// measurement, not an instant grant from the (discarded) JSON seed.
|
|
512
|
+
expect(fresh.getVerifiedBlockWireLength('S_H726100', '03')).to.be.null;
|
|
513
|
+
expect(fresh.recordMeasuredWireLength('S_H726100', '03', 28)).to.deep.equal({ status: 'first-measurement', length: 28 });
|
|
514
|
+
expect(fresh.getVerifiedBlockWireLength('S_H726100', '03')).to.be.null;
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('forgets only the listed blocks, leaving every other block (same or different group) untouched', () => {
|
|
518
|
+
learning.recordMeasuredWireLength('idm701100', '03', 41);
|
|
519
|
+
learning.recordMeasuredWireLength('idm701100', '03', 41);
|
|
520
|
+
learning.recordMeasuredWireLength('idm701100', '04', 20);
|
|
521
|
+
learning.recordMeasuredWireLength('idm701100', '04', 20);
|
|
522
|
+
|
|
523
|
+
learning.forgetMeasuredWireLengths('idm701100', ['03']);
|
|
524
|
+
|
|
525
|
+
expect(learning.getVerifiedBlockWireLength('idm701100', '03')).to.be.null;
|
|
526
|
+
expect(learning.getVerifiedBlockWireLength('idm701100', '04'), 'an untouched block must be unaffected').to.equal(20);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('is a no-op for an unknown version or an empty/missing block list - never throws', () => {
|
|
530
|
+
learning.recordMeasuredWireLength('idm701100', '03', 41);
|
|
531
|
+
learning.recordMeasuredWireLength('idm701100', '03', 41);
|
|
532
|
+
|
|
533
|
+
expect(() => learning.forgetMeasuredWireLengths(undefined, ['03'])).to.not.throw();
|
|
534
|
+
expect(() => learning.forgetMeasuredWireLengths('no-such-version', ['03'])).to.not.throw();
|
|
535
|
+
expect(() => learning.forgetMeasuredWireLengths('idm701100', [])).to.not.throw();
|
|
536
|
+
|
|
537
|
+
expect(learning.getVerifiedBlockWireLength('idm701100', '03'), 'none of the above should have touched the real entry').to.equal(41);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
450
540
|
});
|
|
451
541
|
|
|
452
542
|
describe('firmwareSupportsMultiBlockRequestsForBlocks / firmwareSupportsMultiBlockRequestsForSensors', () => {
|
package/lib/idm-session.js
CHANGED
|
@@ -158,10 +158,15 @@ class IdmSession {
|
|
|
158
158
|
// giving the next turn to whichever group the pattern names next (see
|
|
159
159
|
// beginGroupCollection()) - it does not get interrupted partway through, so the pattern
|
|
160
160
|
// is what settles how often each group gets a turn, not how long any one turn takes.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
161
|
+
// Simple 1:1 alternation, sensor first (not just tied, but first) so the very first turn
|
|
162
|
+
// after connecting - and every settings turn - is bracketed by a fresh sensor read rather
|
|
163
|
+
// than opening the connection with the slower-changing settings data. An earlier version
|
|
164
|
+
// gave sensor 2 turns for every 1 settings turn, back when a settings turn could take
|
|
165
|
+
// several times longer than a sensor turn to actually finish (many re-asks against real
|
|
166
|
+
// hardware) - now that a turn always collects its whole group before yielding either way
|
|
167
|
+
// (see above), that imbalance no longer buys sensor anything extra, so plain alternation
|
|
168
|
+
// is both simpler and gives settings its fair share too.
|
|
169
|
+
this.pollPattern = ['sensor', 'settings'];
|
|
165
170
|
this.pollPatternIndex = 0;
|
|
166
171
|
|
|
167
172
|
// When the current full-coverage cycle started - every sensor block AND every settings
|
|
@@ -320,6 +325,34 @@ class IdmSession {
|
|
|
320
325
|
this.sendQueue.enqueue(message);
|
|
321
326
|
}
|
|
322
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Logs, once per connection (called right after onVersion(), see receive_data()'s 'V'
|
|
330
|
+
* branch), how many of each group's blocks still need a confirming live measurement before
|
|
331
|
+
* that group can switch to multi-block requests (see
|
|
332
|
+
* idm.firmwareSupportsMultiBlockRequestsForBlocks() / getVerifiedBlockWireLength()) - 0 means
|
|
333
|
+
* the group starts on multi-block requests right away (every block's wireLength was already
|
|
334
|
+
* confirmed - either a JSON-declared one confirmed earlier, or one restored and reconfirmed
|
|
335
|
+
* from a previous run's persisted state); anything more means the classic one-block-at-a-time
|
|
336
|
+
* round-robin runs for a little while first, passively confirming each one from real traffic
|
|
337
|
+
* (see learnWireLengthFrom()) before this group's turns switch over.
|
|
338
|
+
*/
|
|
339
|
+
logWireLengthMeasurementStatus() {
|
|
340
|
+
const groups = [
|
|
341
|
+
['sensor', this.idm.getSensorDataBlocks(this.version)],
|
|
342
|
+
['settings', this.idm.getSettingsDataBlocks(this.version)],
|
|
343
|
+
];
|
|
344
|
+
for (const [group, dataBlocks] of groups) {
|
|
345
|
+
if (!dataBlocks || dataBlocks.length === 0) continue;
|
|
346
|
+
const unconfirmed = dataBlocks.filter((b) => this.idm.getVerifiedBlockWireLength(this.version, b) === null);
|
|
347
|
+
if (unconfirmed.length === 0) {
|
|
348
|
+
this.log.info(group + ': every block\'s wireLength is already confirmed - starting on multi-block requests right away');
|
|
349
|
+
} else {
|
|
350
|
+
this.log.info(group + ': ' + unconfirmed.length + ' of ' + dataBlocks.length + ' block(s) (' + unconfirmed.join(',') +
|
|
351
|
+
') still need a confirming measurement before switching to multi-block requests - reading them one at a time for now');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
323
356
|
// ---- connecting / reconnecting -----------------------------------------------------
|
|
324
357
|
|
|
325
358
|
/** Starts (or restarts) the connection process. Call once the adapter is ready. */
|
|
@@ -1012,6 +1045,7 @@ class IdmSession {
|
|
|
1012
1045
|
this.setConnected(true);
|
|
1013
1046
|
this.AdjustSpeed();
|
|
1014
1047
|
this.hooks.onVersion(version);
|
|
1048
|
+
this.logWireLengthMeasurementStatus();
|
|
1015
1049
|
}
|
|
1016
1050
|
if (this.needToSendData) {
|
|
1017
1051
|
this.needToSendData = this.write_data_to_heatpump(false);
|
|
@@ -1060,6 +1094,13 @@ class IdmSession {
|
|
|
1060
1094
|
* adaptive backoff, WITHOUT sending another 0171 - the control already knows what was
|
|
1061
1095
|
* requested from the initial one. Mirrors the single-block 'Data' branch's end-of-cycle
|
|
1062
1096
|
* bookkeeping (recordBlockRead) as each block is found, not just at the end.
|
|
1097
|
+
*
|
|
1098
|
+
* A reply that couldn't be fully parsed (idm.parse_multi_block_reply()'s `error`) is treated
|
|
1099
|
+
* as a sign that one of the group's wireLengths is no longer correct - not a routine partial
|
|
1100
|
+
* reply - so on top of recording whatever blocks WERE successfully parsed before the error,
|
|
1101
|
+
* this immediately abandons the collection and forgets every one of the group's wireLengths
|
|
1102
|
+
* (see idm.forgetMeasuredWireLengths()), forcing a clean re-measurement rather than
|
|
1103
|
+
* continuing to (mis)use an assumption that just proved wrong.
|
|
1063
1104
|
* @param {string} received_data
|
|
1064
1105
|
*/
|
|
1065
1106
|
handleMultiBlockDataReply(received_data) {
|
|
@@ -1072,9 +1113,6 @@ class IdmSession {
|
|
|
1072
1113
|
return;
|
|
1073
1114
|
}
|
|
1074
1115
|
const { blocks, error } = this.idm.parse_multi_block_reply(this.version, received_data, this.hooks.onFieldUpdate);
|
|
1075
|
-
if (error) {
|
|
1076
|
-
this.log.warn('multi-block reply could not be fully parsed (' + error + '), ' + blocks.length + ' block(s) from it are still usable');
|
|
1077
|
-
}
|
|
1078
1116
|
|
|
1079
1117
|
let foundNew = false;
|
|
1080
1118
|
for (const { block, text } of blocks) {
|
|
@@ -1088,6 +1126,15 @@ class IdmSession {
|
|
|
1088
1126
|
this.hooks.onDataBlockText('Data_block_' + Number.parseInt(block, 16), text);
|
|
1089
1127
|
this.recordBlockRead(block);
|
|
1090
1128
|
}
|
|
1129
|
+
|
|
1130
|
+
if (error) {
|
|
1131
|
+
this.log.warn('multi-block reply for ' + group + ' could not be fully parsed (' + error + ') - a wireLength for one of its ' +
|
|
1132
|
+
'blocks is probably no longer correct; forcing re-measurement of every ' + group + ' block');
|
|
1133
|
+
this.idm.forgetMeasuredWireLengths(this.version, this.groupDataBlocks(group));
|
|
1134
|
+
this.finishGroupCollection();
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1091
1138
|
this.log.debug('multi-block reply (' + group + '): found ' + blocks.map((b) => b.block).join(',') +
|
|
1092
1139
|
', still missing ' + [...collector.missing].join(',') + ' after ' + collector.reaskCount + ' re-ask(s)');
|
|
1093
1140
|
|
package/lib/idm-session.test.js
CHANGED
|
@@ -263,28 +263,26 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
263
263
|
it('logs the elapsed time and a running total only once every distinct data block has actually been read, each time the lap completes', () => {
|
|
264
264
|
const socket = connect();
|
|
265
265
|
// idm701100 has 4 sensor blocks and 5 settings blocks - 9 distinct block ids total.
|
|
266
|
-
// pollPattern
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
266
|
+
// pollPattern alternates sensor/settings 1:1 (see its own comment), so a full-coverage
|
|
267
|
+
// cycle needs exactly 10 turns: 5 sensor turns (easily enough to cycle its 4-block
|
|
268
|
+
// round robin more than once over) and exactly 5 settings turns - one full round trip
|
|
269
|
+
// of its 5-block round robin, which is what actually gates it (sensor's smaller group
|
|
270
|
+
// always finishes its own round robin first and just keeps re-cycling).
|
|
270
271
|
const fullCoverageLogs = () => log.info.getCalls().filter(c => /^full-coverage cycle #/.test(c.args[0]));
|
|
271
272
|
|
|
272
273
|
socket.emit('data', versionResponse('idm701100')); // sensor block 07 in flight (turn 1)
|
|
273
274
|
|
|
274
|
-
for (let i = 0; i <
|
|
275
|
-
expect(fullCoverageLogs(), 'only 1 of 5 settings blocks read so far').to.have.lengthOf(0);
|
|
276
|
-
|
|
277
|
-
for (let i = 0; i < 7; i++) completeCurrentBlockAndAdvance(socket); // turns 6-12: settings 04, 05, 06
|
|
275
|
+
for (let i = 0; i < 8; i++) completeCurrentBlockAndAdvance(socket); // turns 1-8: settings 03, 04, 05, 06
|
|
278
276
|
expect(fullCoverageLogs(), 'only 4 of 5 settings blocks read so far').to.have.lengthOf(0);
|
|
279
277
|
|
|
280
|
-
for (let i = 0; i <
|
|
278
|
+
for (let i = 0; i < 2; i++) completeCurrentBlockAndAdvance(socket); // turns 9-10: settings 08 - the last one
|
|
281
279
|
|
|
282
280
|
// All 9 distinct ids have now been read once, completing the very first lap - but there's
|
|
283
281
|
// no predecessor to time yet, so this isn't logged until the *next* lap completes.
|
|
284
282
|
expect(fullCoverageLogs(), 'the very first lap has no predecessor to time yet').to.have.lengthOf(0);
|
|
285
283
|
|
|
286
|
-
// Second lap (
|
|
287
|
-
for (let i = 0; i <
|
|
284
|
+
// Second lap (10 more turns) - now there's a predecessor, so this one does get logged.
|
|
285
|
+
for (let i = 0; i < 10; i++) completeCurrentBlockAndAdvance(socket);
|
|
288
286
|
let logs = fullCoverageLogs();
|
|
289
287
|
expect(logs, 'expected a log call once the second lap completes').to.have.lengthOf(1);
|
|
290
288
|
// idm701100's 4 sensor + 5 settings blocks, all still at the normalDataContentDelay
|
|
@@ -293,7 +291,7 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
293
291
|
expect(logs[0].args[0]).to.match(/^full-coverage cycle #1 done in \d+ms, delays: \(07,09,0A,0B,03,04,05,06,08\) 650ms$/);
|
|
294
292
|
|
|
295
293
|
// A third lap - the running total should go up, not reset.
|
|
296
|
-
for (let i = 0; i <
|
|
294
|
+
for (let i = 0; i < 10; i++) completeCurrentBlockAndAdvance(socket);
|
|
297
295
|
logs = fullCoverageLogs();
|
|
298
296
|
expect(logs, 'expected a second log call once the next lap completes').to.have.lengthOf(2);
|
|
299
297
|
expect(logs[1].args[0]).to.match(/^full-coverage cycle #2 done in \d+ms, delays: /);
|
|
@@ -649,6 +647,24 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
649
647
|
const ALL_SENSOR_BLOCKS = ['07', '0B', '0C', '0D']; // must match S_H726100.json's sensorBlocks
|
|
650
648
|
const ALL_SETTINGS_BLOCKS = ['03', '04', '05', '06', '08', '09', '0A']; // must match S_H726100.json's settingsBlocks
|
|
651
649
|
|
|
650
|
+
// A JSON-declared "wireLength" is only a seeded CANDIDATE now (see
|
|
651
|
+
// getVerifiedBlockWireLength()'s comment), not an instant grant - it still needs one fresh
|
|
652
|
+
// confirming measurement before its group qualifies for multi-block requests. Every test in
|
|
653
|
+
// this describe block is about the multi-block REQUEST/REPLY mechanics themselves (that
|
|
654
|
+
// transition has its own dedicated tests, in the "wireLength learning" describe block below),
|
|
655
|
+
// and assumes S_H726100 is ALREADY fully multi-block-capable for both groups from turn 1 - so
|
|
656
|
+
// this confirms every one of its blocks directly via recordMeasuredWireLength(), bypassing the
|
|
657
|
+
// learnWireLengthFrom() stub the outer beforeEach installs (recordMeasuredWireLength is called
|
|
658
|
+
// straight on `idm`, not through the stubbed session method), exactly what one real confirming
|
|
659
|
+
// read per block would do. PAYLOADS' lengths are exactly S_H726100.json's declared wireLengths
|
|
660
|
+
// (see the comment above), so the first (and only) call for each block always matches its seed
|
|
661
|
+
// and confirms immediately.
|
|
662
|
+
beforeEach(() => {
|
|
663
|
+
for (const [block, payload] of Object.entries(PAYLOADS)) {
|
|
664
|
+
idm.recordMeasuredWireLength('S_H726100', block, payload.length / 2);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
652
668
|
/**
|
|
653
669
|
* A small test double for the REAL S_H726100 control's observed multi-block behavior,
|
|
654
670
|
* built directly from this feature's own captured real traffic: acks a 0171 multi-block
|
|
@@ -770,22 +786,20 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
770
786
|
expect(session.activeMultiBlockGroup).to.equal('sensor');
|
|
771
787
|
});
|
|
772
788
|
|
|
773
|
-
it('happy path: one full pattern cycle (sensor,
|
|
789
|
+
it('happy path: one full pattern cycle (sensor, settings), each group completing on its first content reply', () => {
|
|
774
790
|
const socket = connectAsH726100();
|
|
775
791
|
new MultiBlockControllerSim(socket, session, {}); // both groups use their default (complete) reply
|
|
776
792
|
|
|
777
793
|
completeGroupTurn(socket); // turn 1: sensor
|
|
778
|
-
|
|
779
|
-
//
|
|
780
|
-
// inspected right at the end of this pattern cycle, before turn 4 (the version reply
|
|
794
|
+
// Turn 2 (settings) is driven WITHOUT the trailing version reply, so the state can be
|
|
795
|
+
// inspected right at the end of this pattern cycle, before turn 3 (the version reply
|
|
781
796
|
// completeGroupTurn() would otherwise supply immediately begins the next cycle's
|
|
782
797
|
// sensor collection and reassigns activeMultiBlockGroup again).
|
|
783
|
-
clock.tick(10000); // turn
|
|
798
|
+
clock.tick(10000); // turn 2: settings
|
|
784
799
|
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
|
|
788
|
-
expect(hooks.onDataBlockText.callCount).to.equal(15);
|
|
800
|
+
// One full pattern cycle is exactly one sensor turn and one settings turn now (1:1
|
|
801
|
+
// alternation - see pollPattern's comment): 4 + 7 = 11, one read of every distinct block.
|
|
802
|
+
expect(hooks.onDataBlockText.callCount).to.equal(11);
|
|
789
803
|
for (const id of [...ALL_SENSOR_BLOCKS, ...ALL_SETTINGS_BLOCKS]) {
|
|
790
804
|
expect(hooks.onDataBlockText.calledWith('Data_block_' + Number.parseInt(id, 16)),
|
|
791
805
|
'expected a call for block ' + id).to.be.true;
|
|
@@ -802,8 +816,7 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
802
816
|
const socket = connectAsH726100();
|
|
803
817
|
new MultiBlockControllerSim(socket, session, { settings: [['03', '04'], ['05', '06', '08', '09', '0A']] });
|
|
804
818
|
|
|
805
|
-
completeGroupTurn(socket); // turn 1: sensor
|
|
806
|
-
completeGroupTurn(socket); // turn 2: sensor
|
|
819
|
+
completeGroupTurn(socket); // turn 1: sensor - turn 2 (settings) is about to begin
|
|
807
820
|
|
|
808
821
|
const writesBeforeSettingsTurn = socket.written.length;
|
|
809
822
|
// A little slack (+50ms) beyond the exact sum of delays: sinon's fake timers don't
|
|
@@ -838,8 +851,9 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
838
851
|
],
|
|
839
852
|
});
|
|
840
853
|
|
|
841
|
-
completeGroupTurn(socket); // sensor
|
|
842
|
-
completeGroupTurn
|
|
854
|
+
completeGroupTurn(socket); // turn 1: sensor - turn 2 (settings) is about to begin
|
|
855
|
+
// Driven without the trailing version reply (unlike completeGroupTurn), so this stays
|
|
856
|
+
// on turn 2's own collection rather than also kicking off turn 3.
|
|
843
857
|
clock.tick(20000); // settings: both content requests happen within this one collection
|
|
844
858
|
|
|
845
859
|
expect(hooks.onDataBlockText.withArgs('Data_block_3').callCount, 'block 03 must only be reported once').to.equal(1);
|
|
@@ -857,10 +871,11 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
857
871
|
],
|
|
858
872
|
});
|
|
859
873
|
|
|
860
|
-
completeGroupTurn(socket); // sensor
|
|
861
|
-
completeGroupTurn(socket); // sensor
|
|
874
|
+
completeGroupTurn(socket); // turn 1: sensor - turn 2 (settings) is about to begin
|
|
862
875
|
// All 4 content requests (03+04, REPEAT, REPEAT, the rest) happen within this ONE
|
|
863
876
|
// settings collection - the backoff between them (900, 1800, 3600ms) comfortably fits.
|
|
877
|
+
// Driven without the trailing version reply (unlike completeGroupTurn), so this stays
|
|
878
|
+
// on turn 2's own collection rather than also kicking off turn 3.
|
|
864
879
|
clock.tick(20000); // settings
|
|
865
880
|
|
|
866
881
|
expect(session.multiBlockCollector).to.be.null;
|
|
@@ -873,8 +888,7 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
873
888
|
const socket = connectAsH726100();
|
|
874
889
|
new MultiBlockControllerSim(socket, session, { settings: [['03', '04'], ['05', '06', '08', '09', '0A']] });
|
|
875
890
|
|
|
876
|
-
completeGroupTurn(socket); // sensor
|
|
877
|
-
completeGroupTurn(socket); // sensor
|
|
891
|
+
completeGroupTurn(socket); // turn 1: sensor - turn 2 (settings) is about to begin
|
|
878
892
|
clock.tick(session.requestDataBlockDelay + session.multiBlockContentDelay + 50); // settings: 0171 + first (partial) 0172 reply
|
|
879
893
|
|
|
880
894
|
expect(session.activeMultiBlockGroup, 'settings collection has not finished yet').to.equal('settings');
|
|
@@ -896,8 +910,7 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
896
910
|
const socket = connectAsH726100();
|
|
897
911
|
new MultiBlockControllerSim(socket, session, { settings: ['NR', 'NR', [...ALL_SETTINGS_BLOCKS]] });
|
|
898
912
|
|
|
899
|
-
completeGroupTurn(socket); // sensor
|
|
900
|
-
completeGroupTurn(socket); // sensor
|
|
913
|
+
completeGroupTurn(socket); // turn 1: sensor - turn 2 (settings) is about to begin
|
|
901
914
|
// Driven without the trailing version reply (unlike completeGroupTurn), so the state
|
|
902
915
|
// can be inspected right as this collection completes, before the next one begins.
|
|
903
916
|
clock.tick(10000); // settings - two NRs then the full reply, all within this one collection
|
|
@@ -916,8 +929,7 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
916
929
|
// makes some progress but (for whatever reason) never completes the rest.
|
|
917
930
|
new MultiBlockControllerSim(socket, session, { settings: [['03']] });
|
|
918
931
|
|
|
919
|
-
completeGroupTurn(socket); // sensor
|
|
920
|
-
completeGroupTurn(socket); // sensor
|
|
932
|
+
completeGroupTurn(socket); // turn 1: sensor - turn 2 (settings) is about to begin
|
|
921
933
|
// All multiBlockMaxReasks (20) re-asks happen inside this single settings collection:
|
|
922
934
|
// the adaptive backoff grows to multiBlockReaskMaxDelay (4000ms) after a few repeats,
|
|
923
935
|
// so the worst case (900+1800+3600+17*4000ms, plus the initial request delays) is
|
|
@@ -950,6 +962,60 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
950
962
|
expect(session.currentDataBlock, 'the plain single-block path should be in use instead').to.equal('07');
|
|
951
963
|
expect(socket.written.length).to.equal(2); // init + exactly one single-block request, not a batch
|
|
952
964
|
});
|
|
965
|
+
|
|
966
|
+
it('a reply that cannot be fully parsed (lost sync) abandons the collection and forgets the whole group\'s wireLengths, falling back to round-robin', () => {
|
|
967
|
+
// Driven entirely by hand (no MultiBlockControllerSim) - this needs to inject one
|
|
968
|
+
// deliberately malformed reply at an exact point, which the sim (real block ids/NR/
|
|
969
|
+
// REPEAT scripts only) cannot produce.
|
|
970
|
+
const socket = connectAsH726100();
|
|
971
|
+
expect(idm.firmwareSupportsMultiBlockRequests('S_H726100'), 'settings starts out multi-block-capable').to.be.true;
|
|
972
|
+
|
|
973
|
+
// Turn 1 (sensor) - completes normally, using a real full reply.
|
|
974
|
+
clock.tick(session.requestDataBlockDelay); // sends the sensor 0171
|
|
975
|
+
socket.emit('data', idm.create_message('01F10000')); // R1 ack
|
|
976
|
+
clock.tick(session.multiBlockContentDelay); // sends the sensor 0172
|
|
977
|
+
socket.emit('data', idm.create_message('01F200' + ALL_SENSOR_BLOCKS.map((id) => id + SENSOR_PAYLOADS[id]).join('')));
|
|
978
|
+
clock.tick(session.requestInitDelay); // re-init roundtrip -> turn 2 (settings) begins
|
|
979
|
+
socket.emit('data', versionResponse('S_H726100'));
|
|
980
|
+
|
|
981
|
+
// Turn 2 (settings) - a lost-sync reply.
|
|
982
|
+
clock.tick(session.requestDataBlockDelay); // sends the settings 0171
|
|
983
|
+
expect(session.activeMultiBlockGroup).to.equal('settings');
|
|
984
|
+
socket.emit('data', idm.create_message('01F10000')); // R1 ack
|
|
985
|
+
clock.tick(session.multiBlockContentDelay); // sends the settings 0172
|
|
986
|
+
|
|
987
|
+
// A reply idm.parse_multi_block_reply() can't make sense of (an unknown block id,
|
|
988
|
+
// 'ZZ', after a real block 09) - lost sync, exactly like the dedicated
|
|
989
|
+
// idm-protocol.test.js regression for this same payload shape.
|
|
990
|
+
socket.emit('data', idm.create_message('01F200' + '09' + SETTINGS_PAYLOADS['09'] + 'ZZ' + '00'));
|
|
991
|
+
|
|
992
|
+
expect(log.warn.calledWithMatch(/multi-block reply for settings could not be fully parsed/)).to.be.true;
|
|
993
|
+
// The collection is abandoned outright (not left waiting for more re-asks that could
|
|
994
|
+
// never resolve the mismatch), and every one of settings' wireLengths is gone - not
|
|
995
|
+
// just block 09's - since a lost-sync error doesn't say which one was actually wrong.
|
|
996
|
+
expect(session.activeMultiBlockGroup).to.be.null;
|
|
997
|
+
expect(session.multiBlockCollector).to.be.null;
|
|
998
|
+
expect(idm.firmwareSupportsMultiBlockRequests('S_H726100'), 'every settings wireLength was forgotten').to.be.false;
|
|
999
|
+
// Block 09 itself WAS parsed cleanly before the error, so it's still reported once.
|
|
1000
|
+
expect(hooks.onDataBlockText.calledWith('Data_block_9')).to.be.true;
|
|
1001
|
+
|
|
1002
|
+
// Turn 3 (sensor again) - unaffected, still multi-block-capable, completes normally.
|
|
1003
|
+
clock.tick(session.requestInitDelay); // re-init roundtrip -> turn 3 begins
|
|
1004
|
+
socket.emit('data', versionResponse('S_H726100'));
|
|
1005
|
+
expect(session.activeMultiBlockGroup, 'sensor is untouched by settings\' forgotten wireLengths').to.equal('sensor');
|
|
1006
|
+
clock.tick(session.requestDataBlockDelay);
|
|
1007
|
+
socket.emit('data', idm.create_message('01F10000'));
|
|
1008
|
+
clock.tick(session.multiBlockContentDelay);
|
|
1009
|
+
socket.emit('data', idm.create_message('01F200' + ALL_SENSOR_BLOCKS.map((id) => id + SENSOR_PAYLOADS[id]).join('')));
|
|
1010
|
+
clock.tick(session.requestInitDelay); // re-init roundtrip -> turn 4 (settings) begins
|
|
1011
|
+
socket.emit('data', versionResponse('S_H726100'));
|
|
1012
|
+
|
|
1013
|
+
// Turn 4 (settings) - falls back to the classic one-block-at-a-time path instead of
|
|
1014
|
+
// trying (and failing) to send another multi-block request.
|
|
1015
|
+
expect(session.activeMultiBlockGroup, 'settings must not attempt multi-block again until re-confirmed').to.be.null;
|
|
1016
|
+
clock.tick(session.requestDataBlockDelay);
|
|
1017
|
+
expect(ALL_SETTINGS_BLOCKS, 'a plain single-block request for one of settings\' own blocks').to.include(session.currentDataBlock);
|
|
1018
|
+
});
|
|
953
1019
|
});
|
|
954
1020
|
|
|
955
1021
|
describe('wireLength learning (see IdmSession#learnWireLengthFrom / IdmProtocol#recordMeasuredWireLength)', () => {
|
|
@@ -1061,9 +1127,21 @@ describe('idm-session (connection + request/response state machine)', () => {
|
|
|
1061
1127
|
clock.tick(session.contentDelayForCurrentBlock());
|
|
1062
1128
|
socket.emit('data', idm.create_message('01F20007' + '00'.repeat(10)));
|
|
1063
1129
|
clock.tick(session.requestInitDelay);
|
|
1064
|
-
socket.emit('data', versionResponse('idm701100')); // -> turn 2 (
|
|
1130
|
+
socket.emit('data', versionResponse('idm701100')); // -> turn 2 (settings - pollPattern alternates 1:1) begins
|
|
1131
|
+
|
|
1132
|
+
// idm701100's settings blocks were never touched above, so turn 2 is still the classic
|
|
1133
|
+
// single-block path - sensor's own graduation must not affect the OTHER group, and
|
|
1134
|
+
// pollPattern's next sensor turn isn't until turn 3.
|
|
1135
|
+
expect(session.activeMultiBlockGroup, 'turn 2 (settings) has not graduated - still classic single-block').to.be.null;
|
|
1136
|
+
|
|
1137
|
+
clock.tick(session.requestDataBlockDelay);
|
|
1138
|
+
socket.emit('data', idm.create_message('01F10000'));
|
|
1139
|
+
clock.tick(session.contentDelayForCurrentBlock());
|
|
1140
|
+
socket.emit('data', idm.create_message('01F20007' + '00'.repeat(10)));
|
|
1141
|
+
clock.tick(session.requestInitDelay);
|
|
1142
|
+
socket.emit('data', versionResponse('idm701100')); // -> turn 3 (sensor again) begins
|
|
1065
1143
|
|
|
1066
|
-
expect(session.activeMultiBlockGroup, 'turn
|
|
1144
|
+
expect(session.activeMultiBlockGroup, 'turn 3 should go straight to a multi-block collection').to.equal('sensor');
|
|
1067
1145
|
const writesBefore = socket.written.length;
|
|
1068
1146
|
clock.tick(session.requestDataBlockDelay);
|
|
1069
1147
|
expect(idm_u.get_string_uint8array(socket.written[writesBefore])).to.equal(
|