iobroker.idm-multitalent_002 2.2.0 → 2.2.2

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
@@ -28,26 +28,46 @@ Currently following versions are supported (if your version is not listed but yo
28
28
  | EVR-II100201 | EVR752 (EVR752101) | support in development currently, one experimental installation |
29
29
  | TERRA130601 | S_H726 (S_H726100) | supported, one installation |
30
30
 
31
- You need a Ethernet to RS422 converter to connect to the multitalent control.
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 is polled as fast as the protocol allows (throttled to at most once every 10s), while settings is polled only once every 60s, plus once right away after you change a value yourself - see the Architecture section below for the exact schedule. 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. 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
- 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
-
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.
37
-
38
- Example installation:
31
+ Sensor values are polled as fast as the protocol allows (at most once every 10s), settings values
32
+ much less often (once every 60s, plus right away after you change one yourself) - see
33
+ [Architecture](#architecture) below if you want the exact details, they're not needed to get
34
+ running. A value you change yourself is sent to the heat pump right away, but the state only shows
35
+ as acknowledged once it's actually been read back on the next turn, so it can take a few seconds to
36
+ visibly update.
37
+
38
+ ## Hardware setup
39
+ You need an Ethernet-to-RS422 converter (also called a serial server or serial device server) to
40
+ connect to the multitalent control - the adapter talks to it over the network (TCP), and it talks
41
+ to the heat pump's control board over RS422. The screenshots and setup below use a Moxa serial
42
+ server as an example; any Ethernet-to-RS422 converter that can be configured with the serial
43
+ settings below should work.
39
44
 
40
45
  ![system overview](resources/idm%20RS422%20Anschluss.drawio.png)
41
46
 
42
- Settings of the serial adapter:
43
- ```
44
- Baud Rate(bps) 19200
45
- Parity Even
46
- Data Bit 8
47
- Stop Bit 1
48
- Flow Control None
49
- UART FIFO Disable
50
- ```
47
+ 1. Connect the converter to your network (LAN) - this is what the adapter will connect to, so it
48
+ needs an IP address reachable from your ioBroker host.
49
+ 2. Wire the converter's serial side to the 4-pin plug on the **back** of the Multitalent.002
50
+ control display: `Tx-`, `Tx+`, `Rx-`, `Rx+`, matching the same labels on the converter.
51
+ **Important:** also connect the converter's ground/shield to the control's/heat pump's ground -
52
+ without this, electrical interference can corrupt sensor readings.
53
+ 3. Configure the converter's own serial port settings (in the converter's own web interface, not
54
+ in ioBroker) to match what the control expects:
55
+ ```
56
+ Baud Rate(bps) 19200
57
+ Parity Even
58
+ Data Bit 8
59
+ Stop Bit 1
60
+ Flow Control None
61
+ UART FIFO Disable
62
+ ```
63
+ 4. Install the adapter (see [Installation](#installation) below) and, when adding an instance,
64
+ enter the converter's IP address and TCP port in the instance configuration.
65
+
66
+ **Known quirk - heat pump reboots (e.g. after a power loss):** while the control is booting, it
67
+ should not be polled. The adapter does **not** currently detect this on its own, so if the control
68
+ fails to start with the adapter running, stop the adapter, power-cycle the control, wait for it to
69
+ finish booting, then start the adapter again. A delayed switch-on of the serial connection is
70
+ already built in to reduce how often this happens, but it isn't a complete fix yet.
51
71
 
52
72
  Example screenshots of objects:
53
73
  ![Heizkreis A](resources/ioBrokerAdapter-HKA.jpg)
@@ -56,6 +76,14 @@ Example screenshots of objects:
56
76
  ![Status](resources/ioBrokerAdapter-Status.jpg)
57
77
 
58
78
  ## Changelog
79
+ ### 2.2.2 (2026-09-13)
80
+ * (zloe) scheduler tests (sensor/settings priority, sweep completion, no starvation, debug-level logging) now run against every bundled firmware, not just idm701100/S_H726100 - and against a synthetic, non-bundled definition too, proving the scheduler doesn't just happen to work for the shapes this project ships, but for any valid "Custom data blocks directory" file a user adds themselves
81
+ * (zloe) docs: the hardware/RS422 setup instructions have their own clear, numbered "Hardware setup" section now, instead of being mixed into the intro alongside a duplicate (and out-of-place) explanation of the polling architecture
82
+ * (zloe) housekeeping ahead of submitting to the official ioBroker adapter repository: cleaned up devDependencies already provided by `@iobroker/testing`, updated `@iobroker/testing` and pinned `@types/node` to the actually supported Node range, removed the now-redundant `.npmignore`, added Node.js 26.x to the CI test matrix, and removed a stale `common.news` entry for a version that was never actually published to npm
83
+
84
+ ### 2.2.1 (2026-09-13)
85
+ * (zloe) the per-sweep log line introduced in 2.2.0 (`sensor sweep done in ...`) is now logged at debug level instead of info - in normal operation a sensor sweep completes roughly every 10s, which was far too chatty for info level. In its place, a new info-level summary is logged once every 10 minutes with a small statistic (sweep count, min/avg/max duration) per group covering that window
86
+
59
87
  ### 2.2.0 (2026-09-11)
60
88
  * (zloe) sensor and settings are no longer polled in lockstep: sensor is read as fast as the protocol allows (throttled to at most once every 10s), settings only once every 60s plus once right away after any value is written to the heat pump - both intervals measured from the start of one full sweep to the start of the next, and neither configurable. This noticeably reduces how often the heat pump's own control gets polled for the slower-changing settings data, without making sensor data any less fresh
61
89
  * (zloe) a group's sweep (round-robin or multi-block alike) is never interrupted by the other group becoming due partway through - previously only a multi-block collection had this guarantee; the classic one-block-at-a-time round-robin now gets it too
@@ -178,7 +206,9 @@ Example screenshots of objects:
178
206
  As the adapter is not (yet) listed in the official ioBroker repository, install it through the Admin UI rather than a direct npm command:
179
207
  1. In ioBroker Admin, go to **Adapters** and click the **"+" (custom install from URL)** icon in the top right
180
208
  1. Paste `https://github.com/zloe/ioBroker.idm-multitalent_002` (or, for a specific released version, `iobroker.idm-multitalent_002@x.y.z`) into the field and confirm
181
- 1. Once installed, add an instance as usual and configure it
209
+ 1. Once installed, add an instance and, in its configuration, enter the IP address and TCP port of
210
+ your Ethernet-to-RS422 converter (see [Hardware setup](#hardware-setup) above if you haven't
211
+ wired that up yet)
182
212
 
183
213
  ## Developer manual
184
214
  The serial protocol itself was reverse-engineered (RS422 sniffing, no official iDM documentation exists) mainly by user "makki" in the [KNX-User-Forum "idm Wärmepumpe" thread](https://knx-user-forum.de/forum/%C3%B6ffentlicher-bereich/knx-eib-forum/1251-idm-w%C3%A4rmepumpe) and refined further here; see also the [ioBroker adapter thread](https://forum.iobroker.net/topic/54253/test-adapter-idm-multitalent_002). Neither source documents official min/max limits for the writable values (see below) - they only cover which register a value lives in and how it is encoded.
@@ -265,13 +295,20 @@ sequenceDiagram
265
295
  ```
266
296
 
267
297
  Each completed sweep logs its own actual elapsed time plus a rolling average over the last 10
268
- sweeps, e.g. `sensor sweep done in 1400ms (avg of last 10: 1360ms)` - one line per group, logged
269
- the moment that group's sweep finishes. This replaces the earlier combined "full-coverage cycle"
270
- line: now that the two groups run on independent schedules there is no single shared interval left
271
- to time them together, so each gets its own line instead. As before, this is driven by data
298
+ sweeps at **debug** level, e.g. `sensor sweep done in 1400ms (avg of last 10: 1360ms)` - one line
299
+ per group, logged the moment that group's sweep finishes. As before, this is driven by data
272
300
  actually being *received* (`recordBlockRead()`), not merely requested - a block that was asked for
273
301
  but never got a reply (a retry, a response-watchdog reset, the periodic resync) does not count.
274
302
 
303
+ Since a sensor sweep normally completes roughly every 10s, debug is the right level for that
304
+ per-sweep detail - logging it at info would flood the log in normal operation. Instead, once every
305
+ 10 minutes (fixed), one small **info**-level summary line reports both groups' activity over that
306
+ window, e.g. `last 10min - sensor: 58 sweep(s), 1360ms avg (min 1210ms, max 1890ms); settings: 9
307
+ sweep(s), 640ms avg (min 600ms, max 720ms)` - or `0 sweeps` for a group that hasn't completed one
308
+ in that window. This is the only sweep-related log at info level now; it replaces the earlier
309
+ combined "full-coverage cycle" line, which no longer made sense once the two groups started running
310
+ on independent schedules.
311
+
275
312
  #### Multi-block requests, per group
276
313
 
277
314
  The serial protocol allows requesting several data blocks in one `0171` message, combined in a
package/io-package.json CHANGED
@@ -1,8 +1,34 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "idm-multitalent_002",
4
- "version": "2.2.0",
4
+ "version": "2.2.2",
5
5
  "news": {
6
+ "2.2.2": {
7
+ "en": "scheduler tests now cover every bundled firmware plus a synthetic custom definition, not just two; hardware/RS422 setup now has its own clear, step-by-step section in the README; general housekeeping ahead of submitting to the official ioBroker adapter repository (dependency cleanup, CI matrix, stale changelog entry removed)",
8
+ "de": "Scheduler-Tests laufen jetzt für alle mitgelieferten Firmwares sowie eine synthetische Custom-Definition, nicht nur für zwei; die Hardware-/RS422-Einrichtung hat jetzt einen eigenen, klaren Schritt-für-Schritt-Abschnitt im README; allgemeine Aufräumarbeiten vor der Einreichung ins offizielle ioBroker-Adapter-Repository (Abhängigkeiten bereinigt, CI-Matrix erweitert, veralteten Changelog-Eintrag entfernt)",
9
+ "ru": "тесты планировщика теперь охватывают все встроенные прошивки, а также синтетическое пользовательское определение, а не только два; настройка оборудования/RS422 теперь описана в отдельном понятном пошаговом разделе README; общая подготовка перед подачей заявки в официальный репозиторий адаптеров ioBroker (очистка зависимостей, расширение CI-матрицы, удаление устаревшей записи журнала изменений)",
10
+ "pt": "os testes do agendador agora cobrem todos os firmwares incluídos, além de uma definição personalizada sintética, não apenas dois; a configuração de hardware/RS422 agora tem sua própria seção clara e passo a passo no README; organização geral antes do envio ao repositório oficial de adaptadores do ioBroker (limpeza de dependências, matriz de CI, remoção de entrada obsoleta do changelog)",
11
+ "nl": "scheduler-tests dekken nu elke meegeleverde firmware plus een synthetische aangepaste definitie, niet slechts twee; de hardware-/RS422-installatie heeft nu een eigen duidelijke, stapsgewijze sectie in de README; algemene opschoning voorafgaand aan indiening bij de officiële ioBroker-adapterrepository (afhankelijkheden opgeschoond, CI-matrix uitgebreid, verouderde changelog-vermelding verwijderd)",
12
+ "fr": "les tests de l'ordonnanceur couvrent désormais chaque firmware fourni ainsi qu'une définition personnalisée synthétique, pas seulement deux; la configuration matérielle/RS422 dispose désormais de sa propre section claire et détaillée dans le README; nettoyage général avant la soumission au dépôt officiel des adaptateurs ioBroker (dépendances nettoyées, matrice CI étendue, entrée de changelog obsolète supprimée)",
13
+ "it": "i test dello scheduler ora coprono ogni firmware incluso più una definizione personalizzata sintetica, non solo due; la configurazione hardware/RS422 ha ora una propria sezione chiara e dettagliata nel README; pulizia generale in vista dell'invio al repository ufficiale degli adattatori ioBroker (dipendenze ripulite, matrice CI ampliata, voce del changelog obsoleta rimossa)",
14
+ "es": "las pruebas del planificador ahora cubren cada firmware incluido más una definición personalizada sintética, no solo dos; la configuración de hardware/RS422 ahora tiene su propia sección clara y paso a paso en el README; limpieza general antes de enviar al repositorio oficial de adaptadores de ioBroker (dependencias depuradas, matriz de CI ampliada, entrada obsoleta del changelog eliminada)",
15
+ "pl": "testy harmonogramu obejmują teraz każdy dołączony firmware oraz syntetyczną, niestandardową definicję, a nie tylko dwa; konfiguracja sprzętu/RS422 ma teraz własną, przejrzystą, krok po kroku sekcję w README; ogólne porządki przed zgłoszeniem do oficjalnego repozytorium adapterów ioBroker (uporządkowane zależności, rozszerzona macierz CI, usunięty nieaktualny wpis dziennika zmian)",
16
+ "zh-cn": "调度器测试现在覆盖了每个内置固件以及一个合成的自定义定义,而不仅仅是两个;硬件/RS422 设置现在在 README 中有了自己清晰的分步说明部分;在提交到官方 ioBroker 适配器仓库之前进行了一些常规整理(清理依赖项、扩展 CI 测试矩阵、移除过时的更新日志条目)",
17
+ "uk": "тести планувальника тепер охоплюють кожну вбудовану прошивку, а також синтетичне користувацьке визначення, а не лише два; налаштування апаратного забезпечення/RS422 тепер має власний чіткий покроковий розділ у README; загальне впорядкування перед поданням до офіційного репозиторію адаптерів ioBroker (очищено залежності, розширено матрицю CI, видалено застарілий запис журналу змін)"
18
+ },
19
+ "2.2.1": {
20
+ "en": "the per-sweep log line introduced in 2.2.0 (\"sensor sweep done in ...\") is now logged at debug level instead of info - a sensor sweep normally completes roughly every 10s, which was too chatty for info level. In its place, a small info-level summary is now logged once every 10 minutes, with a statistic (sweep count, min/avg/max duration) per group covering that window",
21
+ "de": "die in 2.2.0 eingeführte Log-Zeile pro Durchlauf (\"sensor sweep done in ...\") wird jetzt auf Debug-Level statt Info protokolliert - ein Sensor-Durchlauf ist normalerweise etwa alle 10s abgeschlossen, was auf Info-Level zu viel Rauschen erzeugte. Stattdessen wird jetzt alle 10 Minuten eine kleine Info-Level-Zusammenfassung protokolliert, mit einer Statistik (Anzahl Durchläufe, min/durchschnittliche/max Dauer) pro Gruppe für dieses Zeitfenster",
22
+ "ru": "строка журнала для каждого прохода, добавленная в 2.2.0 (\"sensor sweep done in ...\"), теперь записывается на уровне debug вместо info - проход сенсоров обычно завершается примерно каждые 10с, что было слишком часто для уровня info. Вместо этого теперь раз в 10 минут записывается небольшая сводка на уровне info со статистикой (количество проходов, мин/среднее/макс длительность) по каждой группе за этот период",
23
+ "pt": "a linha de log por varredura introduzida na 2.2.0 (\"sensor sweep done in ...\") agora é registrada em nível debug em vez de info - uma varredura de sensores normalmente é concluída a cada 10s, o que era muito frequente para o nível info. Em seu lugar, agora é registrado um pequeno resumo em nível info a cada 10 minutos, com uma estatística (contagem de varreduras, duração mín/média/máx) por grupo naquele período",
24
+ "nl": "de per-ronde logregel die in 2.2.0 werd geïntroduceerd (\"sensor sweep done in ...\") wordt nu op debug-niveau gelogd in plaats van info - een sensorronde is normaal ongeveer elke 10s voltooid, wat te veel ruis gaf op info-niveau. In plaats daarvan wordt nu elke 10 minuten een kleine samenvatting op info-niveau gelogd, met een statistiek (aantal rondes, min/gemiddelde/max duur) per groep over die periode",
25
+ "fr": "la ligne de journal par tour introduite dans la 2.2.0 (\"sensor sweep done in ...\") est désormais enregistrée au niveau debug au lieu de info - un tour des capteurs se termine normalement environ toutes les 10s, ce qui était trop fréquent pour le niveau info. À la place, un petit résumé de niveau info est maintenant enregistré toutes les 10 minutes, avec une statistique (nombre de tours, durée min/moyenne/max) par groupe sur cette période",
26
+ "it": "la riga di log per giro introdotta nella 2.2.0 (\"sensor sweep done in ...\") viene ora registrata a livello debug invece che info - un giro dei sensori normalmente si completa circa ogni 10s, il che era troppo frequente per il livello info. Al suo posto, ora viene registrato un piccolo riepilogo a livello info ogni 10 minuti, con una statistica (numero di giri, durata min/media/max) per gruppo relativa a quel periodo",
27
+ "es": "la línea de registro por pasada introducida en la 2.2.0 (\"sensor sweep done in ...\") ahora se registra en nivel debug en lugar de info - una pasada de sensores normalmente se completa cada 10s aproximadamente, lo cual era demasiado frecuente para el nivel info. En su lugar, ahora se registra un pequeño resumen en nivel info cada 10 minutos, con una estadística (número de pasadas, duración mín/media/máx) por grupo durante ese periodo",
28
+ "pl": "linia dziennika dla każdego przebiegu wprowadzona w 2.2.0 (\"sensor sweep done in ...\") jest teraz zapisywana na poziomie debug zamiast info - przebieg czujników zwykle kończy się co około 10s, co było zbyt częste dla poziomu info. Zamiast tego co 10 minut zapisywane jest teraz małe podsumowanie na poziomie info, ze statystyką (liczba przebiegów, min/średni/maks czas trwania) dla każdej grupy w tym okresie",
29
+ "zh-cn": "2.2.0 中引入的每轮采集日志行(\"sensor sweep done in ...\")现在改为以 debug 级别记录,而不是 info 级别——传感器一轮采集通常约每10秒完成一次,这在 info 级别下过于频繁。取而代之的是,现在每10分钟记录一条小型 info 级别摘要,包含该分组在此期间的统计信息(采集次数、最小/平均/最大耗时)",
30
+ "uk": "рядок журналу для кожного проходу, доданий у 2.2.0 (\"sensor sweep done in ...\"), тепер записується на рівні debug замість info - прохід датчиків зазвичай завершується приблизно кожні 10с, що було занадто часто для рівня info. Натомість тепер раз на 10 хвилин записується невеликий підсумок на рівні info зі статистикою (кількість проходів, мін/середня/макс тривалість) для кожної групи за цей період"
31
+ },
6
32
  "2.2.0": {
7
33
  "en": "sensor and settings are no longer polled in lockstep: sensor is read as fast as the protocol allows (throttled to at most once every 10s), while settings is only read once every 60s, plus once right away after any value is written to the heat pump - reducing how often the heat pump's own control gets polled for the slower-changing settings data, without making sensor data any less fresh. A group's sweep - round-robin or multi-block alike - is never interrupted by the other group becoming due partway through. The combined \"full-coverage cycle\" log line is replaced by one line per group, logged the moment that group's own sweep completes, showing its actual elapsed time and a rolling average over its last 10 sweeps",
8
34
  "de": "Sensor- und Settings-Abfragen laufen nicht mehr im Gleichschritt: Sensor wird so schnell wie das Protokoll es erlaubt gelesen (gedrosselt auf höchstens einmal alle 10s), während Settings nur alle 60s gelesen wird, plus einmal sofort nachdem ein Wert zur Wärmepumpe geschrieben wurde - das reduziert, wie oft die Steuerung der Wärmepumpe für die sich selten ändernden Settings-Daten abgefragt wird, ohne dass Sensordaten dadurch weniger aktuell werden. Ein Durchlauf einer Gruppe - egal ob round-robin oder multi-block - wird nie unterbrochen, nur weil die andere Gruppe währenddessen fällig wird. Die kombinierte \"full-coverage cycle\"-Logzeile wird durch eine Zeile pro Gruppe ersetzt, die genau dann protokolliert wird, wenn der Durchlauf dieser Gruppe abgeschlossen ist, und die tatsächliche verstrichene Zeit sowie einen gleitenden Durchschnitt der letzten 10 Durchläufe zeigt",
@@ -42,19 +68,6 @@
42
68
  "zh-cn": "修复:此前,多区块分组(传感器或设置)的轮询回合在恰好一次请求/应答尝试后就会放弃,并将回合让给另一分组,即使还没有找到该分组的全部区块——在真实硬件上,设置分组通常需要多次重新请求才能完整到达,这使得设置采集几乎和 2.0.0 之前一样慢,尽管传感器数据的新鲜度已经有所改善。现在,一个回合会先采集完整个分组——以自适应退避方式重新请求,与最初 2.0.0 的机制完全一致,现在传感器分组也采用同样的方式——然后才让出回合",
43
69
  "uk": "виправлення: хід multi-block-групи (сенсори або налаштування) раніше завершувався вже після рівно однієї спроби запит/відповідь і передавав чергу іншій групі, навіть якщо ще не всі блоки було знайдено - на реальному обладнанні, де налаштування часто потребують кількох повторних запитів, щоб надійти повністю, це робило збір налаштувань майже таким же повільним, як до версії 2.0.0, попри покращення актуальності даних датчиків. Тепер хід збирає всю групу цілком - повторюючи запити з адаптивною затримкою, точно як у первісному механізмі версії 2.0.0, тепер також для датчиків - перш ніж передати чергу"
44
70
  },
45
- "2.1.1": {
46
- "en": "fix: the README's intro still described the old (pre-2.0.0) polling behavior - a fixed \"~5-6 cycles\" for a full settings refresh, one settings block per cycle - now describes the actual, faster 2.1.0 polling instead",
47
- "de": "Fehlerbehebung: die Einleitung im README beschrieb noch das alte (Vor-2.0.0) Abfrageverhalten - feste \"~5-6 Zyklen\" für eine vollständige Settings-Aktualisierung, ein Settings-Block pro Zyklus - jetzt wird stattdessen das tatsächliche, schnellere 2.1.0-Abfrageverhalten beschrieben",
48
- "ru": "исправление: во введении README всё ещё описывалось старое (до 2.0.0) поведение опроса - фиксированные \"~5-6 циклов\" для полного обновления настроек, один блок настроек за цикл - теперь описано фактическое, более быстрое поведение опроса версии 2.1.0",
49
- "pt": "correção: a introdução do README ainda descrevia o comportamento antigo (pré-2.0.0) de sondagem - \"~5-6 ciclos\" fixos para uma atualização completa das configurações, um bloco de configuração por ciclo - agora descreve a sondagem real e mais rápida da versão 2.1.0",
50
- "nl": "fix: de inleiding van de README beschreef nog het oude (pre-2.0.0) pollgedrag - een vaste \"~5-6 cycli\" voor een volledige update van de instellingen, één instellingenblok per cyclus - beschrijft nu het daadwerkelijke, snellere pollgedrag van 2.1.0",
51
- "fr": "correctif : l'introduction du README décrivait encore l'ancien comportement d'interrogation (avant 2.0.0) - un nombre fixe \"~5-6 cycles\" pour une actualisation complète des réglages, un bloc de réglages par cycle - elle décrit désormais le comportement réel, plus rapide, de la version 2.1.0",
52
- "it": "correzione: l'introduzione del README descriveva ancora il vecchio comportamento di polling (precedente alla 2.0.0) - un fisso \"~5-6 cicli\" per un aggiornamento completo delle impostazioni, un blocco di impostazioni per ciclo - ora descrive il comportamento di polling reale e più veloce della 2.1.0",
53
- "es": "corrección: la introducción del README seguía describiendo el antiguo comportamiento de sondeo (anterior a 2.0.0) - un fijo \"~5-6 ciclos\" para una actualización completa de la configuración, un bloque de configuración por ciclo - ahora describe el comportamiento de sondeo real y más rápido de la versión 2.1.0",
54
- "pl": "poprawka: wstęp w README nadal opisywał stare (sprzed 2.0.0) zachowanie odpytywania - stałe \"~5-6 cykli\" dla pełnego odświeżenia ustawień, jeden blok ustawień na cykl - teraz opisuje rzeczywiste, szybsze odpytywanie w wersji 2.1.0",
55
- "zh-cn": "修复:README 简介部分仍在描述旧版(2.0.0 之前)的轮询行为——固定的\"约 5-6 个周期\"才能完整刷新一次设置、每个周期只读取一个设置区块——现在改为描述 2.1.0 实际的、更快的轮询行为",
56
- "uk": "виправлення: у вступі README досі описувалася стара (до 2.0.0) поведінка опитування - фіксовані \"~5-6 циклів\" для повного оновлення налаштувань, один блок налаштувань за цикл - тепер описано фактичну, швидшу поведінку опитування версії 2.1.0"
57
- },
58
71
  "2.1.0": {
59
72
  "en": "sensor data blocks can now also be collected in one combined request per firmware (not just settings), and the adapter auto-learns each block's wire length from real traffic instead of requiring it to be hand-verified first. Sensor and settings polling is now interleaved so sensor data stays fresh while a settings collection is still in progress",
60
73
  "de": "Sensor-Datenblöcke können jetzt ebenfalls in einer einzigen kombinierten Anfrage pro Firmware gesammelt werden (nicht mehr nur Settings), und der Adapter lernt die Länge jedes Blocks selbstständig aus dem realen Datenverkehr, statt sie händisch verifiziert vorgeben zu müssen. Sensor- und Settings-Abfragen werden jetzt verschränkt durchgeführt, sodass Sensordaten aktuell bleiben, während eine Settings-Sammlung noch läuft",
@@ -80,19 +93,6 @@
80
93
  "pl": "dla S_H726100 wartości ustawień są teraz zbierane w jednym połączonym żądaniu zamiast jednego bloku na cykl odpytywania - pełne odświeżenie ustawień trwa teraz sekundy zamiast około minuty. Pozostałe obsługiwane firmware nie są tym dotknięte",
81
94
  "zh-cn": "对于 S_H726100,设置值现在通过一次合并请求一次性收集,而不再是每个轮询周期只读取一个区块——完整刷新一次设置现在只需几秒钟,而不是大约一分钟。其他所有受支持的固件均不受影响",
82
95
  "uk": "для S_H726100 значення налаштувань тепер збираються одним об'єднаним запитом замість одного блоку за цикл опитування - повне оновлення налаштувань тепер триває секунди замість приблизно хвилини. Усі інші підтримувані прошивки це не стосується"
83
- },
84
- "1.3.10": {
85
- "en": "fix: a retry on an already finely-tuned data block no longer jumps the delay up by the full coarse step - it now corrects by the same small step the tuning had converged to, and only escalates back up if retries keep actually recurring",
86
- "de": "fix: ein Retry auf einen bereits fein eingestellten Datenblock springt das Delay nicht mehr um den vollen groben Schritt hoch - er korrigiert jetzt um denselben kleinen Schritt, auf den die Feinabstimmung bereits konvergiert war, und eskaliert nur weiter, wenn Retries tatsächlich wiederholt auftreten",
87
- "ru": "исправление: повтор для уже точно настроенного блока данных больше не увеличивает задержку сразу на весь грубый шаг - теперь коррекция происходит на тот же малый шаг, до которого сошлась точная настройка, и шаг увеличивается снова только при повторяющихся сбоях",
88
- "pt": "correção: uma nova tentativa em um bloco de dados já ajustado finamente não aumenta mais o atraso pelo passo grosseiro completo - agora corrige pelo mesmo passo pequeno ao qual o ajuste havia convergido, e só aumenta novamente se as tentativas realmente se repetirem",
89
- "nl": "fix: een retry op een al fijn afgesteld datablok verhoogt de vertraging niet meer met de volledige grove stap - er wordt nu gecorrigeerd met dezelfde kleine stap waarnaar de afstelling al was geconvergeerd, en de stap wordt alleen weer groter als retries daadwerkelijk blijven terugkomen",
90
- "fr": "correctif : une nouvelle tentative sur un bloc de données déjà finement ajusté n'augmente plus le délai du pas grossier complet - elle corrige désormais du même petit pas vers lequel l'ajustement avait convergé, et n'escalade à nouveau que si les tentatives se répètent réellement",
91
- "it": "correzione: un retry su un blocco dati già finemente calibrato non aumenta più il ritardo dell'intero passo grossolano - ora corregge con lo stesso piccolo passo a cui la calibrazione era convergente, e aumenta di nuovo solo se i retry continuano davvero a ripetersi",
92
- "es": "corrección: un reintento en un bloque de datos ya ajustado finamente ya no aumenta el retardo en el paso grueso completo - ahora corrige con el mismo paso pequeño al que había convergido el ajuste, y solo vuelve a escalar si los reintentos realmente siguen repitiéndose",
93
- "pl": "poprawka: ponowna próba dla już precyzyjnie dostrojonego bloku danych nie zwiększa już opóźnienia o pełny, zgrubny krok - teraz koryguje o ten sam mały krok, do którego zbiegło dostrajanie, i eskaluje ponownie tylko wtedy, gdy próby faktycznie się powtarzają",
94
- "zh-cn": "修复:对已经精细调优的数据块进行重试时,不再将延迟直接跳增整个粗略步长——现在会按照调优已收敛到的相同小步长进行修正,只有在重试确实反复发生时才会重新升级步长",
95
- "uk": "виправлення: повторна спроба для вже точно налаштованого блока даних більше не збільшує затримку одразу на весь грубий крок - тепер вона коригується тим самим малим кроком, до якого зійшло налаштування, і знову зростає лише якщо повтори справді повторюються"
96
96
  }
97
97
  },
98
98
  "titleLang": {
@@ -125,7 +125,6 @@
125
125
  "zloe <klaus@zloebl.net>"
126
126
  ],
127
127
  "keywords": [
128
- "ioBroker",
129
128
  "iDM",
130
129
  "heating",
131
130
  "heatpump",
@@ -185,6 +185,18 @@ class IdmSession {
185
185
  // finishSweep(), the only place this is read from and pushed to.
186
186
  this.recentSweepDurationsMs = { sensor: [], settings: [] };
187
187
  this.sweepAverageWindow = 10;
188
+ // Per-sweep completions are only logged at debug level (see finishSweep()) - in normal
189
+ // operation a sensor sweep finishes roughly every sensorMinIntervalMs, which is far too
190
+ // chatty for info level. Instead reportSweepStats() (started by start(), see
191
+ // statsReportTimer below) logs one small info-level summary every statsReportIntervalMs,
192
+ // built from the counters below - reset to zero each time it reports.
193
+ this.statsReportIntervalMs = 10 * 60 * 1000; // fixed, not configurable - see class comment
194
+ this.statsReportTimer = null;
195
+ /** @type {{sensor: {count: number, totalMs: number, minMs: number | null, maxMs: number | null}, settings: {count: number, totalMs: number, minMs: number | null, maxMs: number | null}}} */
196
+ this.sweepStatsSinceReport = {
197
+ sensor: { count: 0, totalMs: 0, minMs: null, maxMs: null },
198
+ settings: { count: 0, totalMs: 0, minMs: null, maxMs: null },
199
+ };
188
200
  // Set by enqueueWrite(); consumed (and cleared) the next time chooseNextGroup() is asked -
189
201
  // forces a settings turn right after the WHOLE write queue has fully drained (request_data()
190
202
  // is only ever reached once nothing is left to send - see write_data_to_heatpump()'s
@@ -394,6 +406,8 @@ class IdmSession {
394
406
  /** Starts (or restarts) the connection process. Call once the adapter is ready. */
395
407
  start() {
396
408
  this.connectAndRead();
409
+ if (this.statsReportTimer) this.hooks.clearInterval(this.statsReportTimer);
410
+ this.statsReportTimer = this.hooks.setInterval(this.reportSweepStats.bind(this), this.statsReportIntervalMs);
397
411
  }
398
412
 
399
413
  connectAndRead() {
@@ -724,13 +738,14 @@ class IdmSession {
724
738
  /**
725
739
  * Called once `group`'s sweep has actually covered every one of its blocks (see
726
740
  * recordBlockRead(), the only caller) - logs the sweep's actual elapsed duration and a rolling
727
- * average over the last sweepAverageWindow sweeps (replacing the old combined "full-coverage
728
- * cycle" log, which no longer makes sense now that sensor and settings run on independent
729
- * cadences - see the constructor's turn-scheduling comment), and clears blocksReadThisSweep so
730
- * the NEXT sweep starts counting from zero. Does NOT touch sweepStartedAt[group] itself -
731
- * chooseNextGroup() restamps it fresh the moment this group's next sweep actually begins
732
- * (beginGroupCollection() for a multi-block group, or the first block request of a new
733
- * round-robin lap), since only then is "now" meaningful as that sweep's start.
741
+ * average over the last sweepAverageWindow sweeps at debug level (a sensor sweep normally
742
+ * completes roughly every sensorMinIntervalMs, far too often for info - the periodic info-level
743
+ * summary is reportSweepStats() instead), feeds sweepStatsSinceReport for that summary, and
744
+ * clears blocksReadThisSweep so the NEXT sweep starts counting from zero. Does NOT touch
745
+ * sweepStartedAt[group] itself - chooseNextGroup() restamps it fresh the moment this group's
746
+ * next sweep actually begins (beginGroupCollection() for a multi-block group, or the first
747
+ * block request of a new round-robin lap), since only then is "now" meaningful as that sweep's
748
+ * start.
734
749
  * @param {'sensor'|'settings'} group
735
750
  */
736
751
  finishSweep(group) {
@@ -745,8 +760,34 @@ class IdmSession {
745
760
  const avgMs = Math.round(recent.reduce((sum, ms) => sum + ms, 0) / recent.length);
746
761
  // Short on purpose (see request_data_block()'s block-07 line for the same reasoning) -
747
762
  // this only ever logs once per completed sweep, never more often than that group's own
748
- // sweep actually takes.
749
- this.log.info(group + ' sweep done in ' + elapsedMs + 'ms (avg of last ' + recent.length + ': ' + avgMs + 'ms)');
763
+ // sweep actually takes. debug level only (see reportSweepStats() for the info-level
764
+ // summary) - too frequent for info in normal operation.
765
+ this.log.debug(group + ' sweep done in ' + elapsedMs + 'ms (avg of last ' + recent.length + ': ' + avgMs + 'ms)');
766
+
767
+ const stats = this.sweepStatsSinceReport[group];
768
+ stats.count++;
769
+ stats.totalMs += elapsedMs;
770
+ stats.minMs = stats.minMs == null ? elapsedMs : Math.min(stats.minMs, elapsedMs);
771
+ stats.maxMs = stats.maxMs == null ? elapsedMs : Math.max(stats.maxMs, elapsedMs);
772
+ }
773
+
774
+ /**
775
+ * Logs one small info-level summary of both groups' sweep activity over the last
776
+ * statsReportIntervalMs (fixed at 10 minutes - see the constructor), then resets
777
+ * sweepStatsSinceReport so the next summary only covers the following window. Started as a
778
+ * fixed interval by start() and cleared by stop(); this is the only info-level sweep-related
779
+ * log in normal operation now that finishSweep() logs at debug level.
780
+ */
781
+ reportSweepStats() {
782
+ const describe = (group) => {
783
+ const stats = this.sweepStatsSinceReport[group];
784
+ if (stats.count === 0) return group + ': 0 sweeps';
785
+ const avgMs = Math.round(stats.totalMs / stats.count);
786
+ return group + ': ' + stats.count + ' sweep(s), ' + avgMs + 'ms avg (min ' + stats.minMs + 'ms, max ' + stats.maxMs + 'ms)';
787
+ };
788
+ this.log.info('last ' + Math.round(this.statsReportIntervalMs / 60000) + 'min - ' + describe('sensor') + '; ' + describe('settings'));
789
+ this.sweepStatsSinceReport.sensor = { count: 0, totalMs: 0, minMs: null, maxMs: null };
790
+ this.sweepStatsSinceReport.settings = { count: 0, totalMs: 0, minMs: null, maxMs: null };
750
791
  }
751
792
 
752
793
  /**
@@ -1355,6 +1396,7 @@ class IdmSession {
1355
1396
  if (this.sendSetValueMessageTimeout1) { this.hooks.clearTimeout(this.sendSetValueMessageTimeout1); this.sendSetValueMessageTimeout1 = null; }
1356
1397
  if (this.sendSetValueMessageTimeout2) { this.hooks.clearTimeout(this.sendSetValueMessageTimeout2); this.sendSetValueMessageTimeout2 = null; }
1357
1398
  if (this.pollScheduleTimer) { this.hooks.clearTimeout(this.pollScheduleTimer); this.pollScheduleTimer = null; }
1399
+ if (this.statsReportTimer) { this.hooks.clearInterval(this.statsReportTimer); this.statsReportTimer = null; }
1358
1400
  if (this.client) this.client.destroy();
1359
1401
  }
1360
1402
  }
@@ -10,6 +10,9 @@ const { expect } = require('chai');
10
10
  const sinon = require('sinon');
11
11
  const proxyquire = require('proxyquire').noPreserveCache();
12
12
  const { EventEmitter } = require('events');
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
13
16
 
14
17
  const IdmProtocol = require('./idm-protocol');
15
18
  const idm_u = require('./idm-utils');
@@ -233,6 +236,16 @@ describe('idm-session (connection + request/response state machine)', () => {
233
236
  expect(clock.countTimers()).to.equal(0);
234
237
  });
235
238
 
239
+ it('also clears the periodic statsReportTimer started by start() on stop()', () => {
240
+ connect();
241
+ expect(session.statsReportTimer, 'start() should have scheduled the periodic stats report').to.be.ok;
242
+
243
+ session.stop();
244
+
245
+ expect(session.statsReportTimer, 'statsReportTimer').to.not.be.ok;
246
+ expect(clock.countTimers()).to.equal(0);
247
+ });
248
+
236
249
  describe('sweep tracking reset on disconnect (see setConnected())', () => {
237
250
  it('resets both groups\' sweepStartedAt and cancels a pending idle recheck when the connection drops', () => {
238
251
  connect();
@@ -268,7 +281,7 @@ describe('idm-session (connection + request/response state machine)', () => {
268
281
  // idm701100 has 4 sensor blocks (07,09,0A,0B) - sweepIncomplete() keeps every turn on
269
282
  // sensor until all 4 have been read at least once (see the constructor's "sweep"
270
283
  // comment), regardless of settings' own due-ness in the meantime.
271
- const sensorSweepLogs = () => log.info.getCalls().filter(c => /^sensor sweep done in /.test(c.args[0]));
284
+ const sensorSweepLogs = () => log.debug.getCalls().filter(c => /^sensor sweep done in /.test(c.args[0]));
272
285
 
273
286
  socket.emit('data', versionResponse('idm701100')); // sensor block 07 in flight (1st of 4)
274
287
 
@@ -309,7 +322,7 @@ describe('idm-session (connection + request/response state machine)', () => {
309
322
  it('logs the settings sweep the same way, and its rolling average reflects multiple completed sweeps', () => {
310
323
  session.version = 'idm701100'; // 5 settings blocks: 03,04,05,06,08
311
324
  const settingsBlocks = ['03', '04', '05', '06', '08'];
312
- const settingsSweepLogs = () => log.info.getCalls().filter(c => /^settings sweep done in /.test(c.args[0]));
325
+ const settingsSweepLogs = () => log.debug.getCalls().filter(c => /^settings sweep done in /.test(c.args[0]));
313
326
 
314
327
  session.sweepStartedAt.settings = Date.now();
315
328
  clock.tick(1000);
@@ -328,7 +341,7 @@ describe('idm-session (connection + request/response state machine)', () => {
328
341
  it('caps the rolling average window at the last 10 completed sweeps', () => {
329
342
  session.version = 'idm701100';
330
343
  const settingsBlocks = ['03', '04', '05', '06', '08'];
331
- const settingsSweepLogs = () => log.info.getCalls().filter(c => /^settings sweep done in /.test(c.args[0]));
344
+ const settingsSweepLogs = () => log.debug.getCalls().filter(c => /^settings sweep done in /.test(c.args[0]));
332
345
  const doSweep = (ms) => {
333
346
  session.sweepStartedAt.settings = Date.now();
334
347
  clock.tick(ms);
@@ -343,6 +356,58 @@ describe('idm-session (connection + request/response state machine)', () => {
343
356
  });
344
357
  });
345
358
 
359
+ describe('periodic sweep stats summary (info level, every statsReportIntervalMs, see reportSweepStats())', () => {
360
+ const statsLogs = () => log.info.getCalls().filter(c => /^last \d+min - /.test(c.args[0]));
361
+
362
+ it('is started by start() and logs nothing until statsReportIntervalMs has actually elapsed', () => {
363
+ session.start();
364
+ expect(session.statsReportTimer, 'start() should schedule the periodic report').to.be.ok;
365
+
366
+ clock.tick(session.statsReportIntervalMs - 1);
367
+ expect(statsLogs()).to.have.lengthOf(0);
368
+
369
+ clock.tick(1);
370
+ expect(statsLogs()).to.have.lengthOf(1);
371
+ });
372
+
373
+ it('reports "0 sweeps" for a group that never completed a sweep in the window', () => {
374
+ session.start();
375
+ clock.tick(session.statsReportIntervalMs);
376
+
377
+ expect(statsLogs()[0].args[0]).to.equal('last 10min - sensor: 0 sweeps; settings: 0 sweeps');
378
+ });
379
+
380
+ it('summarizes count/min/avg/max per group and resets the counters afterwards', () => {
381
+ session.version = 'idm701100'; // 5 settings blocks: 03,04,05,06,08
382
+ const settingsBlocks = ['03', '04', '05', '06', '08'];
383
+ session.start();
384
+
385
+ session.sweepStartedAt.settings = Date.now();
386
+ clock.tick(1000);
387
+ for (const b of settingsBlocks) session.recordBlockRead('settings', b);
388
+
389
+ session.sweepStartedAt.settings = Date.now();
390
+ clock.tick(3000);
391
+ for (const b of settingsBlocks) session.recordBlockRead('settings', b);
392
+
393
+ clock.tick(session.statsReportIntervalMs - 4000);
394
+ const logs = statsLogs();
395
+ expect(logs).to.have.lengthOf(1);
396
+ expect(logs[0].args[0]).to.equal('last 10min - sensor: 0 sweeps; settings: 2 sweep(s), 2000ms avg (min 1000ms, max 3000ms)');
397
+
398
+ // Counters must have been reset - a second full window with no further sweeps reports
399
+ // "0 sweeps" again rather than carrying the previous window's numbers forward.
400
+ clock.tick(session.statsReportIntervalMs);
401
+ expect(statsLogs()[1].args[0]).to.equal('last 10min - sensor: 0 sweeps; settings: 0 sweeps');
402
+ });
403
+
404
+ it('keeps firing every statsReportIntervalMs for as long as the session runs', () => {
405
+ session.start();
406
+ clock.tick(session.statsReportIntervalMs * 3);
407
+ expect(statsLogs()).to.have.lengthOf(3);
408
+ });
409
+ });
410
+
346
411
  describe('adaptive per-data-block content delay (hill-climbs a sweet spot, not "wait forever")', () => {
347
412
  const dataBlock07 = '00000000000000000000000000000000000000000B270000000000000000';
348
413
 
@@ -1295,6 +1360,157 @@ describe('idm-session (connection + request/response state machine)', () => {
1295
1360
  });
1296
1361
  });
1297
1362
 
1363
+ describe('group scheduling across every known firmware (not just idm701100/S_H726100 - see lib/datablocks/*.json)', () => {
1364
+ // The scheduling logic itself (sweepIncomplete/chooseNextGroup/finishSweep) never looks at
1365
+ // block CONTENT, only at each group's actual block id list for the connected version - so
1366
+ // unlike the multi-block/wireLength-learning tests (which need real wire bytes and are
1367
+ // deliberately only exercised against S_H726100, the one firmware with hand-verified
1368
+ // wireLengths), these can validate the same core scheduling guarantees against every
1369
+ // bundled firmware's REAL block lists directly, the same way the "sweep timing" and
1370
+ // "group scheduling" describe blocks above already do for idm701100 - just parametrized.
1371
+ // This is what actually answers "how do the other five firmwares behave under 2.2.1's
1372
+ // scheduler" instead of just assuming they behave like idm701100 because nothing else
1373
+ // fails to load.
1374
+ const ALL_KNOWN_VERSIONS = ['idm701100', 'idm712100', 'idm722100', 'idm750100', 'S_H726100', 'EVR752101'];
1375
+
1376
+ for (const version of ALL_KNOWN_VERSIONS) {
1377
+ describe(version, () => {
1378
+ let sensorBlocks, settingsBlocks;
1379
+
1380
+ beforeEach(() => {
1381
+ session.version = version;
1382
+ sensorBlocks = idm.getSensorDataBlocks(version);
1383
+ settingsBlocks = idm.getSettingsDataBlocks(version);
1384
+ });
1385
+
1386
+ it('has both a non-empty sensor and settings block list (sanity - a typo here would silently skip every test below)', () => {
1387
+ expect(sensorBlocks, 'sensorBlocks').to.be.an('array').with.length.greaterThan(0);
1388
+ expect(settingsBlocks, 'settingsBlocks').to.be.an('array').with.length.greaterThan(0);
1389
+ });
1390
+
1391
+ it('picks sensor first when neither group has ever swept', () => {
1392
+ expect(session.chooseNextGroup()).to.equal('sensor');
1393
+ });
1394
+
1395
+ it('keeps every turn on sensor until all of its blocks are read, then hands off to settings (never yet swept, so due)', () => {
1396
+ session.sweepStartedAt.sensor = Date.now();
1397
+ for (const b of sensorBlocks.slice(0, -1)) {
1398
+ session.recordBlockRead('sensor', b);
1399
+ expect(session.chooseNextGroup(), 'sweep not complete yet - must not hand off early').to.equal('sensor');
1400
+ }
1401
+ session.recordBlockRead('sensor', sensorBlocks[sensorBlocks.length - 1]); // completes the sweep
1402
+ expect(session.sweepIncomplete('sensor'), 'finishSweep() should have cleared the in-progress marker').to.be.false;
1403
+ expect(session.chooseNextGroup(), 'settings has never swept, so it is due now').to.equal('settings');
1404
+ });
1405
+
1406
+ it('keeps every turn on settings until all of its blocks are read, logging the completed sweep at debug (not info) level', () => {
1407
+ session.sweepStartedAt.sensor = Date.now(); // just swept - not due again for a while
1408
+ session.sweepStartedAt.settings = Date.now();
1409
+ for (const b of settingsBlocks.slice(0, -1)) {
1410
+ session.recordBlockRead('settings', b);
1411
+ expect(session.chooseNextGroup(), 'sweep not complete yet - must not hand off early').to.equal('settings');
1412
+ }
1413
+ session.recordBlockRead('settings', settingsBlocks[settingsBlocks.length - 1]); // completes the sweep
1414
+
1415
+ expect(log.debug.calledWithMatch(/^settings sweep done in \d+ms/), 'per-sweep completion must log at debug level').to.be.true;
1416
+ expect(log.info.calledWithMatch(/^settings sweep done in /), 'must NOT also log at info level').to.be.false;
1417
+ });
1418
+
1419
+ it('a round-robin lap already partway through settings keeps its turns even while sensor is also due', () => {
1420
+ session.sweepStartedAt.settings = Date.now();
1421
+ session.recordBlockRead('settings', settingsBlocks[0]); // lap started, not finished (no-op if only 1 block)
1422
+ session.sweepStartedAt.sensor = null; // sensor has never swept - "due" by the plain rule too
1423
+
1424
+ if (settingsBlocks.length > 1) {
1425
+ expect(session.chooseNextGroup(), 'the in-progress settings lap must win, not sensor').to.equal('settings');
1426
+ } else {
1427
+ // A single-block settings group finishes its "lap" in one read, same as
1428
+ // completing the sweep outright - nothing left to keep locked in.
1429
+ expect(session.chooseNextGroup()).to.equal('sensor');
1430
+ }
1431
+ });
1432
+ });
1433
+ }
1434
+ });
1435
+
1436
+ describe('group scheduling for a CUSTOM (non-bundled) data block definition (see native.dataBlocksDir / idm_datablocks.resolve())', () => {
1437
+ // Anyone can add a firmware version the adapter doesn't bundle by dropping a file into
1438
+ // the "Custom data blocks directory" setting (see lib/datablocks/README.md) - without
1439
+ // touching the adapter's own code, let alone this test file. The describe block above
1440
+ // proves the scheduler works for every version WE ship; this one proves it works for a
1441
+ // version we deliberately never heard of, built purely from the same public shape a
1442
+ // custom file has to follow (idm_datablocks.validateVersionFile()) - otherwise the
1443
+ // "every known firmware" coverage above would quietly be "every firmware someone on this
1444
+ // project happened to write a JSON file for", not "every firmware the scheduler actually
1445
+ // supports". Block ids and counts here are picked to NOT match any bundled version, so a
1446
+ // bug that accidentally hardcoded e.g. "4 sensor blocks" would show up here, not there.
1447
+ const CUSTOM_VERSION = 'customTestFirmware9000';
1448
+ let tmpDir, sensorBlocks, settingsBlocks;
1449
+
1450
+ /** A minimal, validateVersionFile()-passing field definition - content is irrelevant to scheduling. */
1451
+ function field(functionId) {
1452
+ return { field: 'f' + functionId, description: 'f' + functionId, length: 1, factor: 1, writable: false, function: functionId };
1453
+ }
1454
+
1455
+ beforeEach(() => {
1456
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'idm-session-test-customdir-'));
1457
+ sensorBlocks = ['0C', '0D', '0E']; // 3 - not the 4-block shape any bundled sensor group uses
1458
+ settingsBlocks = ['0F', '10']; // 2 - not the 5/6/7-block shape any bundled settings group uses
1459
+ const versionFile = {
1460
+ version: CUSTOM_VERSION,
1461
+ sensorBlocks,
1462
+ settingsBlocks,
1463
+ speed: 100,
1464
+ data_blocks: [...sensorBlocks, ...settingsBlocks].map((block_number, i) => ({
1465
+ block_number,
1466
+ definition: [field(i)],
1467
+ })),
1468
+ };
1469
+ fs.writeFileSync(path.join(tmpDir, 'custom.json'), JSON.stringify(versionFile));
1470
+ idm.initialize(tmpDir); // layers this on top of the bundled defaults the outer beforeEach already loaded
1471
+ session.version = CUSTOM_VERSION;
1472
+ });
1473
+
1474
+ afterEach(() => {
1475
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1476
+ });
1477
+
1478
+ it('is actually loaded and picked up by the session (sanity)', () => {
1479
+ expect(sensorBlocks).to.deep.equal(session.groupDataBlocks('sensor'));
1480
+ expect(settingsBlocks).to.deep.equal(session.groupDataBlocks('settings'));
1481
+ });
1482
+
1483
+ it('picks sensor first when neither group has ever swept', () => {
1484
+ expect(session.chooseNextGroup()).to.equal('sensor');
1485
+ });
1486
+
1487
+ it('keeps every turn on sensor until all of its blocks are read, then hands off to settings', () => {
1488
+ session.sweepStartedAt.sensor = Date.now();
1489
+ for (const b of sensorBlocks.slice(0, -1)) {
1490
+ session.recordBlockRead('sensor', b);
1491
+ expect(session.chooseNextGroup(), 'sweep not complete yet').to.equal('sensor');
1492
+ }
1493
+ session.recordBlockRead('sensor', sensorBlocks[sensorBlocks.length - 1]);
1494
+ expect(session.chooseNextGroup(), 'settings has never swept, so it is due now').to.equal('settings');
1495
+ });
1496
+
1497
+ it('a round-robin lap already partway through settings keeps its turns even while sensor is also due', () => {
1498
+ session.sweepStartedAt.settings = Date.now();
1499
+ session.recordBlockRead('settings', settingsBlocks[0]); // 1 of 2 - lap started, not finished
1500
+ session.sweepStartedAt.sensor = null;
1501
+
1502
+ expect(session.chooseNextGroup(), 'the in-progress settings lap must win, not sensor').to.equal('settings');
1503
+ });
1504
+
1505
+ it('logs a completed sweep at debug level, with the right elapsed time', () => {
1506
+ session.sweepStartedAt.sensor = Date.now();
1507
+ clock.tick(1234);
1508
+ for (const b of sensorBlocks) session.recordBlockRead('sensor', b);
1509
+
1510
+ expect(log.debug.calledWithMatch(/^sensor sweep done in 1234ms \(avg of last 1: 1234ms\)$/)).to.be.true;
1511
+ });
1512
+ });
1513
+
1298
1514
  describe('write-triggered settings refresh, end to end (see enqueueWrite() / settingsRefreshNeededAfterWrite)', () => {
1299
1515
  it('a completed write forces a prompt settings read on the very next turn, even when settings was not otherwise due', () => {
1300
1516
  const socket = connect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.idm-multitalent_002",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Read and write values of a iDM heatpump with multitalent.002 control.",
5
5
  "author": {
6
6
  "name": "zloe",
@@ -30,24 +30,14 @@
30
30
  "@alcalzone/release-script-plugin-iobroker": "^5.2.0",
31
31
  "@alcalzone/release-script-plugin-license": "^5.2.2",
32
32
  "@eslint/js": "^10.0.1",
33
- "@iobroker/testing": "^5.3.0",
34
- "@types/chai": "^5.2.3",
35
- "@types/chai-as-promised": "^8.0.2",
33
+ "@iobroker/testing": "^6.2.1",
36
34
  "@types/gulp": "^4.0.18",
37
- "@types/mocha": "^10.0.10",
38
- "@types/node": "^26.4.0",
35
+ "@types/node": "^22.20.2",
39
36
  "@types/proxyquire": "^1.3.31",
40
- "@types/sinon": "^22.0.0",
41
- "@types/sinon-chai": "^4.0.0",
42
- "chai": "^6.2.2",
43
37
  "axios": "^1.20.0",
44
- "chai-as-promised": "^8.0.2",
45
38
  "eslint": "^10.9.1",
46
39
  "gulp": "^5.0.1",
47
- "mocha": "^12.0.0",
48
40
  "proxyquire": "^2.1.3",
49
- "sinon": "^22.0.0",
50
- "sinon-chai": "^4.0.1",
51
41
  "typescript": "~7.0.2"
52
42
  },
53
43
  "main": "main.js",