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 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; sensor values are read about twice as often as settings values, so they stay more up to date. Depending on your control's firmware, the adapter either reads a whole group (all sensor values, or all settings values) together in one request, or - for firmwares where that isn't (yet) established - one settings block at a time, round-robin, so a complete refresh of every setting can take a few polling cycles either way (see the Architecture section below for the exact schedule).
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
  ![Status](resources/ioBrokerAdapter-Status.jpg)
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', 'sensor', 'settings']`, cycled via
234
- `pollPatternIndex`): sensor gets two turns for every one settings turn, and always goes first -
235
- both after connecting and after every settings turn - so the freshest-changing data (sensor
236
- readings) is never left waiting behind a settings collection, however long that takes. Within a
237
- turn, each group independently uses either the classic one-block-at-a-time round-robin (unchanged
238
- since the very first version) or a multi-block request for the whole group at once, whichever it
239
- currently qualifies for - see the next two sections. Where a group is multi-block-capable, its turn
240
- collects that WHOLE group - every one of its blocks actually found - before the next turn in the
241
- pattern begins; it is not interrupted partway through (see the next section for why), so the
242
- pattern is what settles how *often* each group gets a turn, not how long any one turn takes.
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 - either
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 one confirmed from real traffic (see
261
- "wireLength learning" below). A firmware's two groups can be in different states (e.g. settings
262
- qualified from its JSON definition while sensor is still being learned, or vice versa); whichever
263
- group doesn't (yet) qualify keeps using the plain one-block-per-turn round-robin, completely
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. Every outcome is logged (routine progress at `info`, a
307
- disagreement at `warn`), and the moment a group's every block becomes trusted this way, that's
308
- logged too and multi-block requests for it start from its very next turn. Each newly CONFIRMED
309
- length is also persisted to the `info.measuredWireLengths` ioBroker state (`onWireLengthLearned`
310
- hook, wired up in `main.js`) as `{"<firmware version>": {"<block id>": <byte length>}}`, and
311
- restored on the next adapter start (`loadMeasuredWireLengths()`, before the session starts) via
312
- `IdmProtocol#seedMeasuredWireLength()` - but only as a CANDIDATE, not as already-trusted: every
313
- restart still re-confirms it with one fresh matching measurement before relying on it again, in
314
- case some heat pump setting turns out to affect a block's length after all. See the "wireLength
315
- learning" tests in `lib/idm-session.test.js` (and `idm-protocol.test.js` for the underlying
316
- confirm/mismatch/seed mechanics).
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.2",
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": {
@@ -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 is trusted immediately; without one, the adapter measures and confirms it for itself
50
- from ordinary traffic at runtime instead (see the main README's "wireLength learning"
51
- section) - either way, once every block in a firmware's sensor or settings group has a
52
- trusted length, that whole group is requested together in one combined multi-block request
53
- instead of one block at a time. Leaving this out just means it takes a little longer to kick
54
- in after every restart, not that it never will.
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)
@@ -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. The largest real data block payload is a
9
- // few hundred hex characters, so this is purely defensive: without it, a stream that never
10
- // sends a terminating ETX (line noise on the RS422/TCP path, a misbehaving serial-to-TCP
11
- // converter, ...) would grow received_data_packet/receive_chksum forever, since only a
12
- // genuine SOH/ETX/EOT byte - never plain length - used to trigger a state change or error.
13
- const MAX_FRAME_LENGTH = 2048;
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(). Learned passively from real single-block traffic for a
47
- // block that has no JSON-declared "wireLength" yet, so that firmware EVENTUALLY qualifies
48
- // for multi-block requests too (see firmwareSupportsMultiBlockRequestsForBlocks())
49
- // without a maintainer having to hand-verify it against a traffic capture first - see
50
- // IdmSession's wireLength-learning hook in receive_data().
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 this is a no-op for real
75
- // operation - but it keeps a test suite that reuses one IdmProtocol instance across
76
- // several initialize() calls (e.g. one per `it`, via a shared beforeEach) from leaking
77
- // measurements learned in one test into the next.
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
- * either a JSON-declared "wireLength" (hand-verified by a maintainer against a real traffic
200
- * capture), or a length CONFIRMED from two consecutive live measurements (see
201
- * recordMeasuredWireLength()/seedMeasuredWireLength()). Deliberately never falls back to the
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) for a block that has no JSON-declared "wireLength" yet - a no-op once the
223
- * block already has a trusted length (see getVerifiedBlockWireLength()), whether from the
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. Two CONSECUTIVE matching measurements are required before a length is
231
- * trusted enough to unlock multi-block requests for its group (see
232
- * firmwareSupportsMultiBlockRequestsForBlocks()) - a disagreement simply restarts the count
233
- * from the new value rather than ever averaging or guessing which one was right. A value
234
- * restored from a previous run (see seedMeasuredWireLength()) counts as the FIRST of those
235
- * two measurements, not a free pass - every restart re-confirms once before its group is
236
- * trusted for multi-block again, exactly like a block seen for the very first time.
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`, restored from a
260
- * PREVIOUS adapter run (see main.js, which persists confirmed measurements to an ioBroker
261
- * state and replays them here via this method on startup, before the connection is started).
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 rather than depending on
267
- * some heat pump setting, so treating a restored value as an unconfirmed candidate - needing
268
- * exactly one fresh matching measurement THIS run, same as any other block - costs one extra
269
- * request per block per restart but means every block, freshly seen or previously learned,
270
- * is held to the same standard rather than a persisted value being trusted blindly forever.
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
- * Currently true for every version whose settings blocks all have a JSON-declared
300
- * "wireLength" (S_H726100) OR have since been fully learned via recordMeasuredWireLength().
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
  */
@@ -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('returns the JSON-declared wireLength when present, without ever touching measuredWireLengths', () => {
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
- // a block that already has a verified length is a no-op for recordMeasuredWireLength
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', () => {
@@ -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
- // Sensor comes first in the pattern (not just proportionally more often) so the very
162
- // first turn after connecting - and every settings turn - is bracketed by fresh sensor
163
- // reads rather than opening the connection with the slower-changing settings data.
164
- this.pollPattern = ['sensor', 'sensor', 'settings'];
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
 
@@ -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 reads sensor twice for every one settings read (see its own comment),
267
- // so a full-coverage cycle needs exactly 15 turns: 10 sensor turns (easily enough to
268
- // cycle its 4-block round robin more than twice over) and exactly 5 settings turns -
269
- // one full round trip of its 5-block round robin, which is what actually gates it.
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 < 5; i++) completeCurrentBlockAndAdvance(socket); // turns 1-5: settings 03 (turn 3)
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 < 3; i++) completeCurrentBlockAndAdvance(socket); // turns 13-15: settings 08 - the last one
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 (15 more turns) - now there's a predecessor, so this one does get logged.
287
- for (let i = 0; i < 15; i++) completeCurrentBlockAndAdvance(socket);
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 < 15; i++) completeCurrentBlockAndAdvance(socket);
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, sensor, settings), each group completing on its first content reply', () => {
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
- completeGroupTurn(socket); // turn 2: sensor (a fresh collection - see pollPattern's comment)
779
- // Turn 3 (settings) is driven WITHOUT the trailing version reply, so the state can be
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 3: settings
798
+ clock.tick(10000); // turn 2: settings
784
799
 
785
- // Sensor is collected TWICE per pattern cycle (2 sensor turns for every 1 settings
786
- // turn - each turn starts a brand new collection since the previous one already
787
- // completed) and settings once: 4 + 4 + 7 = 15, not just 11.
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(socket); // sensor
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 (sensor again) begins
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 2 should go straight to a multi-block collection').to.equal('sensor');
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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.idm-multitalent_002",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Read and write values of a iDM heatpump with multitalent.002 control.",
5
5
  "author": {
6
6
  "name": "zloe",