iobroker.sigenergy 3.1.2 → 3.3.0

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
@@ -49,7 +49,7 @@ Supports the Sigenergy Modbus Protocol V2.9 (released 2026-05-13).
49
49
  | Component | Minimum version |
50
50
  |-----------|-----------------|
51
51
  | **js-controller** | >= 6.0.11 |
52
- | **admin** | >= 8.0.0 |
52
+ | **admin** | >= 7.8.23 |
53
53
  | **Node.js** | >= 22 |
54
54
 
55
55
  ---
@@ -255,6 +255,24 @@ Status and power readings for the DC charger.
255
255
 
256
256
  ## Changelog
257
257
 
258
+ ### 3.3.0 (2026-09-07)
259
+ - (ssbingo) fix: when the device rejects a grouped register read with a Modbus exception, the registers of that group are now read one by one and only the register(s) the device does not implement are excluded from further polling. Previously a single unsupported register silently disabled its whole group for the rest of the adapter runtime, e.g. `dcCharger.runningState` (31513) never received a value when the discharging registers 31514–31518 of the same group were rejected
260
+ - (ssbingo) feat: new state `info.protocolVersion` (number, e.g. `2.9`) next to the textual `info.protocolLevel`, so widgets and scripts can adapt to the detected protocol version without parsing strings
261
+ - (ssbingo) feat: DC charger registers carry the protocol version that introduced them (`since`, from the V2.9 revision history: 31509–31511 V2.6, 31513 V2.8, 31514–31525 V2.9); registers newer than the detected protocol level are not requested, so a V2.8 device no longer gets its DC charger group rejected because of the V2.9 discharging registers
262
+ - (ssbingo) chore: grouped register reading moved to `lib/readGroups.js` and covered by unit tests
263
+ - (ssbingo) feat: all registers dated by the V2.9 revision history now carry `since` via a central `PROTOCOL_SINCE` table in `lib/registers.js` (plant 30088–30192 V2.6, 30194–30268 V2.7, 30276–30285 V2.8, 30272/30274/30286 V2.9, 40046–40048 V2.6, 40049–40068 V2.8, 40157–40159 V2.9, ESS preheating 50000–50183 V2.9; inverter 30613–30619 V2.6, 31066–31105 V2.8; DC charger 41002/41004 V2.9; all PSS and PID registers V2.9) and are skipped on devices with an older protocol level
264
+ - (ssbingo) fix: the one-time control register read (FC03) and the SigenMicro reader use the same per-register fallback as the input register readers; the control registers are read after the protocol detection so the version gating applies to them as well; ESS preheating polling is skipped below protocol V2.9
265
+ - (ssbingo) fix: corrected the protocol version marker in the names of the eleven cumulative energy states 30228-30268 from `(V2.8)` to `(V2.7)`; the revision history of the V2.9 protocol document lists the cumulative energy interface 30196-30268 under V2.7
266
+
267
+ ### 3.2.0 (2026-09-04)
268
+ - (MMeinhardt1) feat: new state `statistics.emsWorkMode` exposing the EMS work mode as plain text, so consumers do not need the `common.states` lookup table
269
+ - (MMeinhardt1) feat: added EMS work mode 6 (Virtual Power Plant) to the register description and the mode map
270
+ - (MMeinhardt1) change: the `*HM` duration states now hold a bare `H:MM` value with unit `h`, instead of embedding an `h` suffix in the value itself
271
+ - (MMeinhardt1) fix: statistics objects are created with `extendObjectAsync`, so corrected units and roles reach installations where the object already exists
272
+ - (ssbingo) chore: admin minimum requirement lowered back to >= 7.8.23 to keep serving admin 7.x installations
273
+ - (ssbingo) fix: restored the JSDoc parameter descriptions on `_formatMinutesAsHM` to keep `npm run lint` warning-free
274
+ - (ssbingo) chore: repository checker findings — track `.vscode/settings.json` deliberately, bump `@alcalzone/release-script-plugin-license` to 5.2.2, pin `testing-action-deploy` to the major version again
275
+
258
276
  ### 3.1.2 (2026-08-06)
259
277
  - (ssbingo) chore: resolved all 28 npm audit findings (3 critical, 11 high) — development tooling only, the published adapter is unchanged
260
278
  - (ssbingo) chore: removed `@iobroker/dev-server`; it was the sole source of the critical findings via the deprecated `request` and `xmldom` packages, for which no fixed release exists. This also removes the `npm start` / `npm run watch` scripts
package/io-package.json CHANGED
@@ -1,8 +1,34 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "sigenergy",
4
- "version": "3.1.2",
4
+ "version": "3.3.0",
5
5
  "news": {
6
+ "3.3.0": {
7
+ "en": "(ssbingo) feat: registers are gated by the detected Modbus protocol version, so a device is no longer asked for registers its firmware does not know; rejected register groups are now retried register by register, so one unsupported register no longer silences its whole group; new state info.protocolVersion",
8
+ "de": "(ssbingo) feat: Register werden anhand der erkannten Modbus-Protokollversion gefiltert, damit ein Gerät nicht mehr nach Registern gefragt wird, die seine Firmware nicht kennt; abgelehnte Registergruppen werden jetzt einzeln nachgelesen, sodass ein nicht unterstütztes Register nicht mehr die ganze Gruppe stilllegt; neuer State info.protocolVersion",
9
+ "ru": "(ssbingo) feat: регистры фильтруются по обнаруженной версии протокола Modbus, поэтому устройство больше не запрашивается о регистрах, неизвестных его прошивке; отклонённые группы регистров перечитываются по одному, поэтому один неподдерживаемый регистр больше не блокирует всю группу; новое состояние info.protocolVersion",
10
+ "pt": "(ssbingo) feat: os registos são filtrados pela versão do protocolo Modbus detetada, pelo que o dispositivo deixa de ser questionado sobre registos que o seu firmware desconhece; os grupos rejeitados passam a ser lidos registo a registo, pelo que um registo não suportado deixa de silenciar todo o grupo; novo estado info.protocolVersion",
11
+ "nl": "(ssbingo) feat: registers worden gefilterd op de gedetecteerde Modbus-protocolversie, zodat een apparaat niet langer wordt bevraagd over registers die zijn firmware niet kent; geweigerde registergroepen worden nu register voor register gelezen, zodat één niet-ondersteund register niet meer de hele groep stillegt; nieuwe state info.protocolVersion",
12
+ "fr": "(ssbingo) feat : les registres sont filtrés selon la version du protocole Modbus détectée, de sorte qu'un appareil n'est plus interrogé sur des registres que son micrologiciel ignore ; les groupes rejetés sont désormais relus registre par registre, de sorte qu'un registre non pris en charge ne neutralise plus tout son groupe ; nouvel état info.protocolVersion",
13
+ "it": "(ssbingo) feat: i registri vengono filtrati in base alla versione del protocollo Modbus rilevata, così il dispositivo non viene più interrogato su registri che il suo firmware non conosce; i gruppi rifiutati vengono ora letti registro per registro, così un registro non supportato non azzera più l'intero gruppo; nuovo stato info.protocolVersion",
14
+ "es": "(ssbingo) feat: los registros se filtran según la versión del protocolo Modbus detectada, de modo que ya no se consulta al dispositivo por registros que su firmware desconoce; los grupos rechazados se leen ahora registro a registro, de modo que un registro no soportado ya no silencia todo su grupo; nuevo estado info.protocolVersion",
15
+ "pl": "(ssbingo) feat: rejestry są filtrowane według wykrytej wersji protokołu Modbus, więc urządzenie nie jest już pytane o rejestry nieznane jego oprogramowaniu; odrzucone grupy rejestrów są teraz odczytywane pojedynczo, więc jeden nieobsługiwany rejestr nie wyłącza już całej grupy; nowy stan info.protocolVersion",
16
+ "uk": "(ssbingo) feat: регістри фільтруються за виявленою версією протоколу Modbus, тому пристрій більше не запитують про регістри, невідомі його прошивці; відхилені групи регістрів тепер перечитуються по одному, тож один непідтримуваний регістр більше не блокує всю групу; новий стан info.protocolVersion",
17
+ "zh-cn": "(ssbingo) feat: 按检测到的 Modbus 协议版本过滤寄存器,不再向设备请求其固件不支持的寄存器;被拒绝的寄存器组改为逐个读取,单个不支持的寄存器不再导致整组失效;新增状态 info.protocolVersion"
18
+ },
19
+ "3.2.0": {
20
+ "en": "(MMeinhardt1) feat: EMS work mode as plain text, Virtual Power Plant mode, h:mm values now carry unit 'h' instead of a suffix; statistics objects are updated on every start. (ssbingo) chore: admin requirement lowered back to >=7.8.23",
21
+ "de": "(MMeinhardt1) feat: EMS-Betriebsmodus als Klartext, Modus Virtual Power Plant, h:mm-Werte tragen jetzt die Einheit 'h' statt eines Suffix; Statistik-Objekte werden bei jedem Start aktualisiert. (ssbingo) chore: admin-Anforderung zurück auf >=7.8.23",
22
+ "ru": "(MMeinhardt1) feat: режим работы EMS в виде текста, режим Virtual Power Plant, значения h:mm теперь имеют единицу 'h' вместо суффикса; объекты статистики обновляются при каждом запуске. (ssbingo) chore: требование к admin снижено до >=7.8.23",
23
+ "pt": "(MMeinhardt1) feat: modo de trabalho EMS como texto, modo Virtual Power Plant, valores h:mm passam a ter a unidade 'h' em vez de sufixo; os objetos de estatística são atualizados em cada arranque. (ssbingo) chore: requisito do admin reduzido para >=7.8.23",
24
+ "nl": "(MMeinhardt1) feat: EMS-werkmodus als tekst, modus Virtual Power Plant, h:mm-waarden krijgen nu eenheid 'h' in plaats van een achtervoegsel; statistiekobjecten worden bij elke start bijgewerkt. (ssbingo) chore: admin-vereiste terug naar >=7.8.23",
25
+ "fr": "(MMeinhardt1) feat : mode de fonctionnement EMS en texte clair, mode Virtual Power Plant, les valeurs h:mm portent désormais l'unité « h » au lieu d'un suffixe ; les objets de statistiques sont mis à jour à chaque démarrage. (ssbingo) chore : exigence admin ramenée à >=7.8.23",
26
+ "it": "(MMeinhardt1) feat: modalità di lavoro EMS come testo, modalità Virtual Power Plant, i valori h:mm ora hanno l'unità 'h' invece di un suffisso; gli oggetti statistici vengono aggiornati a ogni avvio. (ssbingo) chore: requisito admin riportato a >=7.8.23",
27
+ "es": "(MMeinhardt1) feat: modo de trabajo EMS como texto, modo Virtual Power Plant, los valores h:mm llevan ahora la unidad 'h' en lugar de un sufijo; los objetos de estadísticas se actualizan en cada arranque. (ssbingo) chore: requisito de admin reducido a >=7.8.23",
28
+ "pl": "(MMeinhardt1) feat: tryb pracy EMS jako tekst, tryb Virtual Power Plant, wartości h:mm mają teraz jednostkę 'h' zamiast sufiksu; obiekty statystyk są aktualizowane przy każdym starcie. (ssbingo) chore: wymaganie admin obniżone do >=7.8.23",
29
+ "uk": "(MMeinhardt1) feat: режим роботи EMS як текст, режим Virtual Power Plant, значення h:mm тепер мають одиницю 'h' замість суфікса; об'єкти статистики оновлюються при кожному запуску. (ssbingo) chore: вимогу до admin знижено до >=7.8.23",
30
+ "zh-cn": "(MMeinhardt1) feat: EMS 工作模式以纯文本提供、新增 Virtual Power Plant 模式、h:mm 值改用单位 'h' 而非后缀;统计对象在每次启动时更新。(ssbingo) chore: admin 要求下调回 >=7.8.23"
31
+ },
6
32
  "3.1.2": {
7
33
  "en": "(ssbingo) chore: all npm audit findings resolved (28 -> 0); removed @iobroker/dev-server, which pulled in unpatchable deprecated packages. Development tooling only — the adapter itself is unchanged.",
8
34
  "de": "(ssbingo) chore: alle npm-audit-Findings behoben (28 -> 0); @iobroker/dev-server entfernt, das nicht patchbare veraltete Pakete mitzog. Betrifft nur das Entwicklungs-Tooling — der Adapter selbst ist unverändert.",
@@ -121,7 +147,7 @@
121
147
  ],
122
148
  "globalDependencies": [
123
149
  {
124
- "admin": ">=8.0.0"
150
+ "admin": ">=7.8.23"
125
151
  }
126
152
  ],
127
153
  "licenseInformation": {
@@ -245,6 +271,19 @@
245
271
  },
246
272
  "native": {}
247
273
  },
274
+ {
275
+ "_id": "info.protocolVersion",
276
+ "type": "state",
277
+ "common": {
278
+ "name": "Highest Modbus protocol version confirmed by probing (0 = below V2.6 or not detected yet)",
279
+ "type": "number",
280
+ "role": "value",
281
+ "read": true,
282
+ "write": false,
283
+ "def": 0
284
+ },
285
+ "native": {}
286
+ },
248
287
  {
249
288
  "_id": "info.modelType",
250
289
  "type": "state",
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Grouped Modbus register reads.
5
+ *
6
+ * buildReadGroups() merges consecutive register definitions into as few
7
+ * Modbus requests as possible. readRegisterGroups() executes those requests
8
+ * and, when the device rejects a whole group with a Modbus exception
9
+ * (ILLEGAL FUNCTION / ILLEGAL DATA ADDRESS), falls back to reading the
10
+ * registers of that group one by one. Registers the device rejects
11
+ * individually are remembered and excluded from later polls, so a single
12
+ * unsupported register (e.g. one introduced in a newer protocol version or
13
+ * one that does not apply to the installed hardware) no longer prevents the
14
+ * other registers of its group from being read.
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} ReadLog
19
+ * @property {(msg: string) => void} debug - debug logger
20
+ * @property {(msg: string) => void} info - info logger
21
+ * @property {(msg: string) => void} warn - warn logger
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} ModbusError
26
+ * @property {string} message - error message
27
+ * @property {number} [modbusCode] - Modbus exception code, set by modbus-serial on exception responses
28
+ */
29
+
30
+ /**
31
+ * Build optimized sequential read groups
32
+ *
33
+ * @param {object[]} registers - Register definitions ({ addr, qty, ... })
34
+ * @param {number} maxQty - Maximum registers per group
35
+ * @returns {object[]} Grouped register batches ({ startAddr, totalQty, registers })
36
+ */
37
+ function buildReadGroups(registers, maxQty) {
38
+ if (!registers || registers.length === 0) {
39
+ return [];
40
+ }
41
+
42
+ const sorted = [...registers].sort((a, b) => a.addr - b.addr);
43
+ const groups = [];
44
+ let currentGroup = null;
45
+
46
+ for (const reg of sorted) {
47
+ if (!currentGroup) {
48
+ currentGroup = {
49
+ startAddr: reg.addr,
50
+ totalQty: reg.qty,
51
+ registers: [{ ...reg, offset: 0 }],
52
+ };
53
+ } else {
54
+ const expectedNext = currentGroup.startAddr + currentGroup.totalQty;
55
+ const gap = reg.addr - expectedNext;
56
+
57
+ if (gap >= 0 && gap <= 4 && currentGroup.totalQty + gap + reg.qty <= maxQty) {
58
+ const offset = reg.addr - currentGroup.startAddr;
59
+ currentGroup.totalQty = Math.max(currentGroup.totalQty, offset + reg.qty);
60
+ currentGroup.registers.push({ ...reg, offset });
61
+ } else {
62
+ groups.push(currentGroup);
63
+ currentGroup = {
64
+ startAddr: reg.addr,
65
+ totalQty: reg.qty,
66
+ registers: [{ ...reg, offset: 0 }],
67
+ };
68
+ }
69
+ }
70
+ }
71
+ if (currentGroup) {
72
+ groups.push(currentGroup);
73
+ }
74
+ return groups;
75
+ }
76
+
77
+ /**
78
+ * Whether an error is a Modbus exception proving that the device does not
79
+ * implement the requested register(s). modbus-serial sets `modbusCode` on
80
+ * exception responses: 1 = ILLEGAL FUNCTION, 2 = ILLEGAL DATA ADDRESS.
81
+ * Other exception codes and transport errors (timeouts, socket errors)
82
+ * prove nothing about the register and must not exclude it.
83
+ *
84
+ * @param {ModbusError | null | undefined} err - Error thrown by the Modbus client
85
+ * @returns {boolean} true if the device rejected the register(s)
86
+ */
87
+ function isRegisterRejected(err) {
88
+ return !!err && (err.modbusCode === 1 || err.modbusCode === 2);
89
+ }
90
+
91
+ /**
92
+ * Remember a register the device rejected and log it once.
93
+ *
94
+ * @param {string} section - Section name for the log message
95
+ * @param {object} reg - Register definition
96
+ * @param {Set<number>} unsupported - Set of rejected register addresses
97
+ * @param {ModbusError} err - The Modbus exception
98
+ * @param {ReadLog} log - Logger
99
+ */
100
+ function markUnsupported(section, reg, unsupported, err, log) {
101
+ unsupported.add(reg.addr);
102
+ log.info(
103
+ `[${section}] register ${reg.addr} (${reg.name}) is not supported by this device ` +
104
+ `(${err.message}) - excluded from further polling`,
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Read the registers of a rejected group one by one.
110
+ *
111
+ * @param {object} opts - Options, see readRegisterGroups()
112
+ * @param {string} opts.section - Section name
113
+ * @param {object} opts.group - The rejected group
114
+ * @param {Set<number>} opts.unsupported - Set of rejected register addresses
115
+ * @param {(addr: number, qty: number) => Promise<number[]>} opts.read - Register read function
116
+ * @param {(group: object, raw: number[]) => Promise<void>} opts.process - Group processing function
117
+ * @param {(ms: number) => Promise<void>} opts.sleep - Pause function
118
+ * @param {() => boolean} opts.isStopped - Shutdown check
119
+ * @param {ReadLog} opts.log - Logger
120
+ * @returns {Promise<void>} resolves when all registers were tried
121
+ */
122
+ async function readGroupIndividually(opts) {
123
+ const { section, group, unsupported, read, process, sleep, isStopped, log } = opts;
124
+ for (const reg of group.registers) {
125
+ if (isStopped()) {
126
+ return;
127
+ }
128
+ try {
129
+ const raw = await read(reg.addr, reg.qty);
130
+ await process({ startAddr: reg.addr, totalQty: reg.qty, registers: [{ ...reg, offset: 0 }] }, raw);
131
+ } catch (err) {
132
+ if (isRegisterRejected(err)) {
133
+ markUnsupported(section, reg, unsupported, err, log);
134
+ } else {
135
+ log.debug(`[${section}] read error at ${reg.addr} (${reg.name}): ${err.message}`);
136
+ }
137
+ }
138
+ await sleep(50);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Read a register set in grouped requests with per-register fallback.
144
+ *
145
+ * @param {object} opts - Options
146
+ * @param {string} opts.section - Section name used in log messages (e.g. 'dcCharger')
147
+ * @param {object[]} opts.registers - Register definitions to read
148
+ * @param {number} opts.batchSize - Maximum registers per Modbus request
149
+ * @param {Set<number>} opts.unsupported - Addresses rejected by the device; extended by this call and excluded from grouping
150
+ * @param {Set<number>} opts.errorLogged - Group start addresses whose transport error was already logged at warn level
151
+ * @param {(addr: number, qty: number) => Promise<number[]>} opts.read - Reads `qty` registers starting at `addr`
152
+ * @param {(group: object, raw: number[]) => Promise<void>} opts.process - Processes the raw data of one group
153
+ * @param {(ms: number) => Promise<void>} opts.sleep - Pause between requests
154
+ * @param {() => boolean} opts.isStopped - Returns true while the adapter is shutting down
155
+ * @param {ReadLog} opts.log - Logger
156
+ * @returns {Promise<void>} resolves when all groups were processed
157
+ */
158
+ async function readRegisterGroups(opts) {
159
+ const { section, batchSize, unsupported, errorLogged, read, process, sleep, isStopped, log } = opts;
160
+ const registers = opts.registers.filter(r => !unsupported.has(r.addr));
161
+ const groups = buildReadGroups(registers, batchSize);
162
+ log.debug(`[${section}] reading ${registers.length} register(s) in ${groups.length} group(s)`);
163
+
164
+ for (const group of groups) {
165
+ if (isStopped()) {
166
+ return;
167
+ }
168
+ try {
169
+ const raw = await read(group.startAddr, group.totalQty);
170
+ await process(group, raw);
171
+ await sleep(100);
172
+ } catch (err) {
173
+ if (!isRegisterRejected(err)) {
174
+ // Transport error, timeout or a non-conclusive exception: keep the
175
+ // group, warn once and simply retry on the next poll cycle.
176
+ if (!errorLogged.has(group.startAddr)) {
177
+ errorLogged.add(group.startAddr);
178
+ log.warn(
179
+ `[${section}] register read error at ${group.startAddr} (qty ${group.totalQty}): ${err.message}`,
180
+ );
181
+ } else {
182
+ log.debug(`[${section}] read error at ${group.startAddr}: ${err.message}`);
183
+ }
184
+ continue;
185
+ }
186
+ if (group.registers.length === 1) {
187
+ markUnsupported(section, group.registers[0], unsupported, err, log);
188
+ continue;
189
+ }
190
+ log.debug(
191
+ `[${section}] group ${group.startAddr} (qty ${group.totalQty}) rejected by device ` +
192
+ `(${err.message}) - reading its registers individually`,
193
+ );
194
+ await readGroupIndividually({ section, group, unsupported, read, process, sleep, isStopped, log });
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Split registers into those supported by the detected protocol version and
201
+ * those introduced in a newer version (`reg.since`). With an unknown version
202
+ * (0 / undefined) nothing is skipped.
203
+ *
204
+ * @param {object[]} registers - Register definitions, optionally with `since`
205
+ * @param {number} protocolVersion - Detected protocol version (e.g. 2.8), 0 = unknown
206
+ * @returns {{ active: object[], skipped: object[] }} partitioned registers
207
+ */
208
+ function filterByProtocolVersion(registers, protocolVersion) {
209
+ if (!protocolVersion || protocolVersion <= 0) {
210
+ return { active: registers, skipped: [] };
211
+ }
212
+ const skipped = registers.filter(r => typeof r.since === 'number' && r.since > protocolVersion);
213
+ const active = skipped.length > 0 ? registers.filter(r => !skipped.includes(r)) : registers;
214
+ return { active, skipped };
215
+ }
216
+
217
+ module.exports = { buildReadGroups, readRegisterGroups, isRegisterRejected, filterByProtocolVersion };
package/lib/registers.js CHANGED
@@ -37,7 +37,7 @@ const PLANT_READ_REGISTERS = [
37
37
  gain: null,
38
38
  unit: '',
39
39
  role: 'value',
40
- desc: 'EMS work mode: 0=MaxSelfConsumption,1=AI,2=TOU,5=FullFeedIn,7=RemoteEMS,9=Custom',
40
+ desc: 'EMS work mode: 0=MaxSelfConsumption,1=AI,2=TOU,5=FullFeedIn,6=VirtualPowerPlant,7=RemoteEMS,9=Custom',
41
41
  },
42
42
  {
43
43
  addr: 30004,
@@ -1155,7 +1155,7 @@ const PLANT_READ_REGISTERS = [
1155
1155
  gain: 100,
1156
1156
  unit: 'kWh',
1157
1157
  role: 'value.energy',
1158
- desc: 'Total common loads consumption (V2.8)',
1158
+ desc: 'Total common loads consumption (V2.7)',
1159
1159
  feature: 'enableCumulativeEnergy',
1160
1160
  },
1161
1161
  {
@@ -1166,7 +1166,7 @@ const PLANT_READ_REGISTERS = [
1166
1166
  gain: 100,
1167
1167
  unit: 'kWh',
1168
1168
  role: 'value.energy',
1169
- desc: 'Total EVAC charged (V2.8)',
1169
+ desc: 'Total EVAC charged (V2.7)',
1170
1170
  feature: 'enableCumulativeEnergy',
1171
1171
  },
1172
1172
  {
@@ -1177,7 +1177,7 @@ const PLANT_READ_REGISTERS = [
1177
1177
  gain: 100,
1178
1178
  unit: 'kWh',
1179
1179
  role: 'value.energy',
1180
- desc: 'Total self PV generation (V2.8)',
1180
+ desc: 'Total self PV generation (V2.7)',
1181
1181
  feature: 'enableCumulativeEnergy',
1182
1182
  },
1183
1183
  {
@@ -1188,7 +1188,7 @@ const PLANT_READ_REGISTERS = [
1188
1188
  gain: 100,
1189
1189
  unit: 'kWh',
1190
1190
  role: 'value.energy',
1191
- desc: 'Total 3rd party generation (V2.8)',
1191
+ desc: 'Total 3rd party generation (V2.7)',
1192
1192
  feature: 'enableCumulativeEnergy',
1193
1193
  },
1194
1194
  {
@@ -1199,7 +1199,7 @@ const PLANT_READ_REGISTERS = [
1199
1199
  gain: 100,
1200
1200
  unit: 'kWh',
1201
1201
  role: 'value.energy',
1202
- desc: 'Total ESS charged (V2.8)',
1202
+ desc: 'Total ESS charged (V2.7)',
1203
1203
  feature: 'enableCumulativeEnergy',
1204
1204
  },
1205
1205
  {
@@ -1210,7 +1210,7 @@ const PLANT_READ_REGISTERS = [
1210
1210
  gain: 100,
1211
1211
  unit: 'kWh',
1212
1212
  role: 'value.energy',
1213
- desc: 'Total ESS discharged (V2.8)',
1213
+ desc: 'Total ESS discharged (V2.7)',
1214
1214
  feature: 'enableCumulativeEnergy',
1215
1215
  },
1216
1216
  {
@@ -1221,7 +1221,7 @@ const PLANT_READ_REGISTERS = [
1221
1221
  gain: 100,
1222
1222
  unit: 'kWh',
1223
1223
  role: 'value.energy',
1224
- desc: 'Total EVDC charged (V2.8)',
1224
+ desc: 'Total EVDC charged (V2.7)',
1225
1225
  feature: 'enableCumulativeEnergy',
1226
1226
  },
1227
1227
  {
@@ -1232,7 +1232,7 @@ const PLANT_READ_REGISTERS = [
1232
1232
  gain: 100,
1233
1233
  unit: 'kWh',
1234
1234
  role: 'value.energy',
1235
- desc: 'Total EVDC discharged (V2.8)',
1235
+ desc: 'Total EVDC discharged (V2.7)',
1236
1236
  feature: 'enableCumulativeEnergy',
1237
1237
  },
1238
1238
  {
@@ -1243,7 +1243,7 @@ const PLANT_READ_REGISTERS = [
1243
1243
  gain: 100,
1244
1244
  unit: 'kWh',
1245
1245
  role: 'value.energy',
1246
- desc: 'Total imported energy (V2.8)',
1246
+ desc: 'Total imported energy (V2.7)',
1247
1247
  feature: 'enableCumulativeEnergy',
1248
1248
  },
1249
1249
  {
@@ -1254,7 +1254,7 @@ const PLANT_READ_REGISTERS = [
1254
1254
  gain: 100,
1255
1255
  unit: 'kWh',
1256
1256
  role: 'value.energy',
1257
- desc: 'Total exported energy (V2.8)',
1257
+ desc: 'Total exported energy (V2.7)',
1258
1258
  feature: 'enableCumulativeEnergy',
1259
1259
  },
1260
1260
  {
@@ -1265,7 +1265,7 @@ const PLANT_READ_REGISTERS = [
1265
1265
  gain: 100,
1266
1266
  unit: 'kWh',
1267
1267
  role: 'value.energy',
1268
- desc: 'Total oil generator energy (V2.8)',
1268
+ desc: 'Total oil generator energy (V2.7)',
1269
1269
  feature: 'enableCumulativeEnergy',
1270
1270
  },
1271
1271
  // V2.9 — separate FC04 group (gap > 4 from 30087)
@@ -3313,6 +3313,7 @@ const EMS_WORK_MODES = {
3313
3313
  1: 'AI Mode',
3314
3314
  2: 'TOU',
3315
3315
  5: 'Full Feed-in to Grid',
3316
+ 6: 'Virtual Power Plant',
3316
3317
  7: 'Remote EMS',
3317
3318
  9: 'Custom',
3318
3319
  };
@@ -4980,6 +4981,80 @@ const DC_CHARGER_RUNNING_STATES = {
4980
4981
  0x0a: 'Preparing (Insulation)',
4981
4982
  };
4982
4983
 
4984
+ // ============================================================
4985
+ // Protocol version gating
4986
+ // ============================================================
4987
+ // Register address ranges and the Sigenergy Modbus protocol version that
4988
+ // introduced them, taken from the revision history of the protocol document
4989
+ // V2.9. Applied below as `since` to every register definition inside a range
4990
+ // (explicit `since` fields on a register take precedence). Registers newer
4991
+ // than the protocol level detected on the device are not polled, see
4992
+ // main.js _readRegisterGroups() / lib/readGroups.js filterByProtocolVersion().
4993
+ // Address spaces are per Modbus slave, so the ranges are kept per device family.
4994
+ const PROTOCOL_SINCE = {
4995
+ plant: [
4996
+ // V2.6 (2025-03-31)
4997
+ { from: 30088, to: 30192, since: 2.6 }, // PV total generation, total load consumption, smart load 1-24
4998
+ { from: 40046, to: 40048, since: 2.6 }, // ESS backup SOC, charge / discharge cut-off SOC
4999
+ // V2.7 (2025-05-23) - the register table of the document labels 30228-30268
5000
+ // "New statistics interface", the revision history dates 30196-30268 to V2.7
5001
+ { from: 30194, to: 30268, since: 2.7 }, // third party inverter power, cumulative energy registers
5002
+ // V2.8 (2025-11-28)
5003
+ { from: 30276, to: 30285, since: 2.8 }, // grid code rated frequency / voltage, current control command, merged alarm 6 / 7, general / total load power
5004
+ { from: 40049, to: 40068, since: 2.8 }, // active power regulation gradient, grid code registers
5005
+ // V2.9 (2026-05-13)
5006
+ { from: 30272, to: 30275, since: 2.9 }, // PV total daily generation, PV total generation of previous day
5007
+ { from: 30286, to: 30286, since: 2.9 }, // ESS average cell temperature
5008
+ { from: 40157, to: 40159, since: 2.9 }, // PCC power factor targets, grid power loss lockout alarm clear
5009
+ { from: 50000, to: 50183, since: 2.9 }, // ESS preheating TOU registers
5010
+ ],
5011
+ inverter: [
5012
+ // V2.6 (2025-03-31)
5013
+ { from: 30613, to: 30619, since: 2.6 }, // power adjustment feedback values
5014
+ { from: 31509, to: 31512, since: 2.6 }, // [DC Charger] PV daily / total generation
5015
+ // V2.8 (2025-11-28)
5016
+ { from: 31066, to: 31105, since: 2.8 }, // PV voltage / current of additional strings
5017
+ { from: 31513, to: 31513, since: 2.8 }, // [DC Charger] running state
5018
+ // V2.9 (2026-05-13)
5019
+ { from: 31514, to: 31526, since: 2.9 }, // [DC Charger] discharging registers
5020
+ { from: 41002, to: 41005, since: 2.9 }, // [DC Charger] max charging / discharging power limit
5021
+ ],
5022
+ pss: [{ from: 0, to: 65535, since: 2.9 }], // PSS registers were added in V2.9
5023
+ pid: [{ from: 0, to: 65535, since: 2.9 }], // PID registers were added in V2.9
5024
+ };
5025
+
5026
+ /**
5027
+ * Set `since` on every register whose address falls into one of the ranges.
5028
+ *
5029
+ * @param {object[]} registers - Register definitions (mutated)
5030
+ * @param {{ from: number, to: number, since: number }[]} ranges - Address ranges with protocol version
5031
+ * @returns {object[]} the same array, for chaining
5032
+ */
5033
+ function applySince(registers, ranges) {
5034
+ for (const reg of registers) {
5035
+ if (reg.since !== undefined) {
5036
+ continue;
5037
+ }
5038
+ const hit = ranges.find(r => reg.addr >= r.from && reg.addr <= r.to);
5039
+ if (hit) {
5040
+ reg.since = hit.since;
5041
+ }
5042
+ }
5043
+ return registers;
5044
+ }
5045
+
5046
+ applySince(PLANT_READ_REGISTERS, PROTOCOL_SINCE.plant);
5047
+ applySince(PLANT_WRITE_REGISTERS, PROTOCOL_SINCE.plant);
5048
+ applySince(ESS_PREHEATING_WRITE_REGISTERS, PROTOCOL_SINCE.plant);
5049
+ applySince(INVERTER_READ_REGISTERS, PROTOCOL_SINCE.inverter);
5050
+ applySince(INVERTER_WRITE_REGISTERS, PROTOCOL_SINCE.inverter);
5051
+ applySince(DC_CHARGER_READ_REGISTERS, PROTOCOL_SINCE.inverter);
5052
+ applySince(DC_CHARGER_WRITE_REGISTERS, PROTOCOL_SINCE.inverter);
5053
+ applySince(PSS_READ_REGISTERS, PROTOCOL_SINCE.pss);
5054
+ applySince(PSS_WRITE_REGISTERS, PROTOCOL_SINCE.pss);
5055
+ applySince(PID_READ_REGISTERS, PROTOCOL_SINCE.pid);
5056
+ applySince(PID_WRITE_REGISTERS, PROTOCOL_SINCE.pid);
5057
+
4983
5058
  module.exports = {
4984
5059
  PLANT_READ_REGISTERS,
4985
5060
  PLANT_WRITE_REGISTERS,
@@ -4998,4 +5073,6 @@ module.exports = {
4998
5073
  EMS_WORK_MODES,
4999
5074
  REMOTE_EMS_MODES,
5000
5075
  DC_CHARGER_RUNNING_STATES,
5076
+ PROTOCOL_SINCE,
5077
+ applySince,
5001
5078
  };
package/main.js CHANGED
@@ -28,6 +28,7 @@ const {
28
28
  REMOTE_EMS_MODES,
29
29
  DC_CHARGER_RUNNING_STATES,
30
30
  } = require('./lib/registers');
31
+ const { buildReadGroups, readRegisterGroups, filterByProtocolVersion } = require('./lib/readGroups');
31
32
  const SigenMicroScanner = require('./lib/scanner');
32
33
  const { getRegistersForDevice } = require('./lib/sigenMicroRegisters');
33
34
  const adapterVersion = require('./package.json').version;
@@ -134,6 +135,7 @@ class Sigenergy extends utils.Adapter {
134
135
  this._stopped = false;
135
136
  this._controlRegistersRead = false;
136
137
  this._protocolDetected = false;
138
+ this._protocolVersion = 0;
137
139
  this._emergencyWasOffGrid = false;
138
140
  this._emergencyStabilityTimer = null;
139
141
  this._emergencySwitchCount = 0;
@@ -141,12 +143,11 @@ class Sigenergy extends utils.Adapter {
141
143
  this._modelVerified = false;
142
144
  this._pvStringCount = 0;
143
145
  this._essPreheatingUnsupported = false;
144
- this._plantUnsupportedGroups = new Set();
145
- this._inverterUnsupportedGroups = new Set();
146
- this._acChargerUnsupportedGroups = new Set();
147
- this._dcChargerUnsupportedGroups = new Set();
148
- this._pssUnsupportedGroups = new Set();
149
- this._pidUnsupportedGroups = new Set();
146
+ // Per section: register addresses the device rejected (excluded from
147
+ // polling) and group start addresses whose read error was already logged.
148
+ this._unsupportedRegisters = {};
149
+ this._readErrorLogged = {};
150
+ this._sinceSkipLogged = {};
150
151
 
151
152
  this.on('ready', this.onReady.bind(this));
152
153
  this.on('stateChange', this.onStateChange.bind(this));
@@ -403,15 +404,17 @@ class Sigenergy extends utils.Adapter {
403
404
 
404
405
  try {
405
406
  if (this._caps.plant) {
406
- if (!this._controlRegistersRead) {
407
- await this._readControlRegisters();
408
- this._controlRegistersRead = true;
409
- }
410
407
  if (!this._protocolDetected) {
411
408
  // Only latch the flag if detection was conclusive (no transport
412
409
  // errors). Otherwise retry on the next poll cycle.
413
410
  this._protocolDetected = await this._detectProtocolLevel();
414
411
  }
412
+ // Read the control registers after the protocol detection, so
413
+ // registers newer than the detected protocol level are skipped.
414
+ if (!this._controlRegistersRead) {
415
+ await this._readControlRegisters();
416
+ this._controlRegistersRead = true;
417
+ }
415
418
  if (!this._modelVerified) {
416
419
  await this._verifyModelType();
417
420
  }
@@ -525,31 +528,58 @@ class Sigenergy extends utils.Adapter {
525
528
  return true;
526
529
  }
527
530
 
531
+ /**
532
+ * Read the input registers of one section (plant, inverter, acCharger,
533
+ * dcCharger, pss, pid) in grouped FC04 requests. If the device rejects a
534
+ * whole group, its registers are read one by one and only the rejected
535
+ * ones are excluded from further polls (see lib/readGroups.js).
536
+ *
537
+ * @param {string} section - Section name (log tag, key for the unsupported-register bookkeeping)
538
+ * @param {number} slaveId - Modbus slave id
539
+ * @param {object[]} registers - Register definitions
540
+ * @param {number} batchSize - Maximum registers per request
541
+ * @param {boolean} [holding] - Read holding registers (FC03) instead of input registers (FC04)
542
+ * @returns {Promise<void>} resolves when the section was read
543
+ */
544
+ async _readRegisterGroups(section, slaveId, registers, batchSize, holding = false) {
545
+ if (!this._unsupportedRegisters[section]) {
546
+ this._unsupportedRegisters[section] = new Set();
547
+ this._readErrorLogged[section] = new Set();
548
+ }
549
+ // Skip registers introduced in a newer protocol version than the one
550
+ // detected on the device (reg.since, only once detection succeeded).
551
+ const { active, skipped } = filterByProtocolVersion(registers, this._protocolVersion);
552
+ registers = active;
553
+ if (skipped.length > 0 && !this._sinceSkipLogged[section]) {
554
+ this._sinceSkipLogged[section] = true;
555
+ const list = skipped.map(r => `${r.addr} (${r.name}, V${r.since})`).join(', ');
556
+ this.log.info(
557
+ `[${section}] ${skipped.length} register(s) require a newer protocol version than detected ` +
558
+ `(V${this._protocolVersion}) and are not polled: ${list}`,
559
+ );
560
+ }
561
+ this.log.debug(`Reading ${section} (slaveId=${slaveId})`);
562
+ await readRegisterGroups({
563
+ section,
564
+ registers,
565
+ batchSize,
566
+ unsupported: this._unsupportedRegisters[section],
567
+ errorLogged: this._readErrorLogged[section],
568
+ read: (addr, qty) =>
569
+ holding
570
+ ? this.modbus.readHoldingRegisters(slaveId, addr, qty)
571
+ : this.modbus.readInputRegisters(slaveId, addr, qty),
572
+ process: (group, raw) => this._processReadGroup(group, raw, section),
573
+ sleep: ms => this._sleep(ms),
574
+ isStopped: () => this._stopped,
575
+ log: this.log,
576
+ });
577
+ }
578
+
528
579
  async _readPlant() {
529
580
  const plantId = this.config.plantId || 247;
530
- const batchSize = 120;
531
581
  const activeRegs = PLANT_READ_REGISTERS.filter(r => this._regActive(r));
532
- const groups = this._buildReadGroups(activeRegs, batchSize);
533
- this.log.debug(`Reading plant (slaveId=${plantId}): ${groups.length} group(s), ${activeRegs.length} registers`);
534
-
535
- for (const group of groups) {
536
- if (this._stopped) {
537
- return;
538
- }
539
- try {
540
- this.log.debug(`[plant] FC04 addr=${group.startAddr} qty=${group.totalQty}`);
541
- const raw = await this.modbus.readInputRegisters(plantId, group.startAddr, group.totalQty);
542
- await this._processReadGroup(group, raw, 'plant');
543
- await this._sleep(100);
544
- } catch (err) {
545
- if (!this._plantUnsupportedGroups.has(group.startAddr)) {
546
- this._plantUnsupportedGroups.add(group.startAddr);
547
- this.log.warn(`Plant register read error at ${group.startAddr}: ${err.message}`);
548
- } else {
549
- this.log.debug(`[plant] read error at ${group.startAddr}: ${err.message}`);
550
- }
551
- }
552
- }
582
+ await this._readRegisterGroups('plant', plantId, activeRegs, 120);
553
583
  }
554
584
 
555
585
  /**
@@ -557,30 +587,8 @@ class Sigenergy extends utils.Adapter {
557
587
  */
558
588
  async _readInverter() {
559
589
  const inverterId = this.config.inverterId || 1;
560
- const batchSize = 60;
561
590
  const activeRegs = INVERTER_READ_REGISTERS.filter(r => this._regActive(r));
562
- const groups = this._buildReadGroups(activeRegs, batchSize);
563
- this.log.debug(
564
- `Reading inverter (slaveId=${inverterId}): ${groups.length} group(s),` + ` ${activeRegs.length} registers`,
565
- );
566
-
567
- for (const group of groups) {
568
- if (this._stopped) {
569
- return;
570
- }
571
- try {
572
- const raw = await this.modbus.readInputRegisters(inverterId, group.startAddr, group.totalQty);
573
- await this._processReadGroup(group, raw, 'inverter');
574
- await this._sleep(100);
575
- } catch (err) {
576
- if (!this._inverterUnsupportedGroups.has(group.startAddr)) {
577
- this._inverterUnsupportedGroups.add(group.startAddr);
578
- this.log.warn(`Inverter register read error at ${group.startAddr}: ${err.message}`);
579
- } else {
580
- this.log.debug(`[inverter] read error at ${group.startAddr}: ${err.message}`);
581
- }
582
- }
583
- }
591
+ await this._readRegisterGroups('inverter', inverterId, activeRegs, 60);
584
592
  }
585
593
 
586
594
  /**
@@ -588,27 +596,7 @@ class Sigenergy extends utils.Adapter {
588
596
  */
589
597
  async _readAcCharger() {
590
598
  const acChargerId = this.config.acChargerId || 2;
591
- const batchSize = 30;
592
- const groups = this._buildReadGroups(AC_CHARGER_READ_REGISTERS, batchSize);
593
- this.log.debug(`Reading AC charger (slaveId=${acChargerId}): ${groups.length} group(s)`);
594
-
595
- for (const group of groups) {
596
- if (this._stopped) {
597
- return;
598
- }
599
- try {
600
- const raw = await this.modbus.readInputRegisters(acChargerId, group.startAddr, group.totalQty);
601
- await this._processReadGroup(group, raw, 'acCharger');
602
- await this._sleep(100);
603
- } catch (err) {
604
- if (!this._acChargerUnsupportedGroups.has(group.startAddr)) {
605
- this._acChargerUnsupportedGroups.add(group.startAddr);
606
- this.log.warn(`AC Charger register read error at ${group.startAddr}: ${err.message}`);
607
- } else {
608
- this.log.debug(`[acCharger] read error at ${group.startAddr}: ${err.message}`);
609
- }
610
- }
611
- }
599
+ await this._readRegisterGroups('acCharger', acChargerId, AC_CHARGER_READ_REGISTERS, 30);
612
600
  }
613
601
 
614
602
  /**
@@ -616,77 +604,17 @@ class Sigenergy extends utils.Adapter {
616
604
  */
617
605
  async _readDcCharger() {
618
606
  const inverterId = this.config.inverterId || 1;
619
- const batchSize = 10;
620
- const groups = this._buildReadGroups(DC_CHARGER_READ_REGISTERS, batchSize);
621
- this.log.debug(`Reading DC charger via inverter (slaveId=${inverterId}): ${groups.length} group(s)`);
622
-
623
- for (const group of groups) {
624
- if (this._stopped) {
625
- return;
626
- }
627
- try {
628
- const raw = await this.modbus.readInputRegisters(inverterId, group.startAddr, group.totalQty);
629
- await this._processReadGroup(group, raw, 'dcCharger');
630
- await this._sleep(100);
631
- } catch (err) {
632
- if (!this._dcChargerUnsupportedGroups.has(group.startAddr)) {
633
- this._dcChargerUnsupportedGroups.add(group.startAddr);
634
- this.log.warn(`DC Charger register read error at ${group.startAddr}: ${err.message}`);
635
- } else {
636
- this.log.debug(`[dcCharger] read error at ${group.startAddr}: ${err.message}`);
637
- }
638
- }
639
- }
607
+ await this._readRegisterGroups('dcCharger', inverterId, DC_CHARGER_READ_REGISTERS, 10);
640
608
  }
641
609
 
642
610
  async _readPss() {
643
611
  const pssId = this.config.pssSlaveId || 5;
644
- const batchSize = 50;
645
- const groups = this._buildReadGroups(PSS_READ_REGISTERS, batchSize);
646
- this.log.debug(`Reading PSS (slaveId=${pssId}): ${groups.length} group(s)`);
647
-
648
- for (const group of groups) {
649
- if (this._stopped) {
650
- return;
651
- }
652
- try {
653
- const raw = await this.modbus.readInputRegisters(pssId, group.startAddr, group.totalQty);
654
- await this._processReadGroup(group, raw, 'pss');
655
- await this._sleep(100);
656
- } catch (err) {
657
- if (!this._pssUnsupportedGroups.has(group.startAddr)) {
658
- this._pssUnsupportedGroups.add(group.startAddr);
659
- this.log.warn(`PSS register read error at ${group.startAddr}: ${err.message}`);
660
- } else {
661
- this.log.debug(`[pss] read error at ${group.startAddr}: ${err.message}`);
662
- }
663
- }
664
- }
612
+ await this._readRegisterGroups('pss', pssId, PSS_READ_REGISTERS, 50);
665
613
  }
666
614
 
667
615
  async _readPid() {
668
616
  const pidId = this.config.pidSlaveId || 6;
669
- const batchSize = 50;
670
- const groups = this._buildReadGroups(PID_READ_REGISTERS, batchSize);
671
- this.log.debug(`Reading PID (slaveId=${pidId}): ${groups.length} group(s)`);
672
-
673
- for (const group of groups) {
674
- if (this._stopped) {
675
- return;
676
- }
677
- try {
678
- const raw = await this.modbus.readInputRegisters(pidId, group.startAddr, group.totalQty);
679
- await this._processReadGroup(group, raw, 'pid');
680
- await this._sleep(100);
681
- } catch (err) {
682
- if (!this._pidUnsupportedGroups.has(group.startAddr)) {
683
- this._pidUnsupportedGroups.add(group.startAddr);
684
- this.log.warn(`PID register read error at ${group.startAddr}: ${err.message}`);
685
- } else {
686
- this.log.debug(`[pid] read error at ${group.startAddr}: ${err.message}`);
687
- }
688
- }
689
- }
617
+ await this._readRegisterGroups('pid', pidId, PID_READ_REGISTERS, 50);
690
618
  }
691
619
 
692
620
  async _readEssPreheating() {
@@ -698,7 +626,14 @@ class Sigenergy extends utils.Adapter {
698
626
  }
699
627
  const plantId = this.config.plantId || 247;
700
628
  const batchSize = 50;
701
- const groups = this._buildReadGroups(ESS_PREHEATING_WRITE_REGISTERS, batchSize);
629
+ const { active, skipped } = filterByProtocolVersion(ESS_PREHEATING_WRITE_REGISTERS, this._protocolVersion);
630
+ if (active.length === 0 && skipped.length > 0) {
631
+ this.log.debug(
632
+ `[essPreheating] Skipping — registers require protocol V2.9, detected V${this._protocolVersion}`,
633
+ );
634
+ return;
635
+ }
636
+ const groups = this._buildReadGroups(active, batchSize);
702
637
  this.log.debug(`Reading ESS Preheating (slaveId=${plantId}): ${groups.length} group(s)`);
703
638
 
704
639
  let failCount = 0;
@@ -896,8 +831,12 @@ class Sigenergy extends utils.Adapter {
896
831
  }
897
832
 
898
833
  const levelStr = detectedLevel ? `>=${detectedLevel}` : 'pre-V2.6';
834
+ // Numeric twin of info.protocolLevel for consumers (widgets, scripts):
835
+ // highest confirmed protocol version, 0 = below V2.6.
836
+ this._protocolVersion = detectedLevel ? parseFloat(detectedLevel.replace(/^V/, '')) : 0;
899
837
  this.log.info(`Detected protocol level: ${levelStr} (firmware: ${firmwareVersion})`);
900
838
  await this.setStateAsync('info.protocolLevel', { val: levelStr, ack: true });
839
+ await this.setStateAsync('info.protocolVersion', { val: this._protocolVersion, ack: true });
901
840
  return true;
902
841
  }
903
842
 
@@ -950,51 +889,18 @@ class Sigenergy extends utils.Adapter {
950
889
 
951
890
  const plantRw = PLANT_WRITE_REGISTERS.filter(r => r.perm === 'RW' && this._regActive(r));
952
891
  if (plantRw.length > 0) {
953
- for (const group of this._buildReadGroups(plantRw, 50)) {
954
- if (this._stopped) {
955
- return;
956
- }
957
- try {
958
- const raw = await this.modbus.readHoldingRegisters(plantId, group.startAddr, group.totalQty);
959
- await this._processReadGroup(group, raw, 'control');
960
- await this._sleep(100);
961
- } catch (err) {
962
- this.log.debug(`[controlRegs] plant addr=${group.startAddr}: ${err.message}`);
963
- }
964
- }
892
+ await this._readRegisterGroups('control.plant', plantId, plantRw, 50, true);
965
893
  }
966
894
 
967
895
  const invRw = INVERTER_WRITE_REGISTERS.filter(r => r.perm === 'RW' && this._regActive(r));
968
896
  if (invRw.length > 0) {
969
- for (const group of this._buildReadGroups(invRw, 50)) {
970
- if (this._stopped) {
971
- return;
972
- }
973
- try {
974
- const raw = await this.modbus.readHoldingRegisters(inverterId, group.startAddr, group.totalQty);
975
- await this._processReadGroup(group, raw, 'control');
976
- await this._sleep(100);
977
- } catch (err) {
978
- this.log.debug(`[controlRegs] inverter addr=${group.startAddr}: ${err.message}`);
979
- }
980
- }
897
+ await this._readRegisterGroups('control.inverter', inverterId, invRw, 50, true);
981
898
  }
982
899
 
983
900
  if (this.config.hasDcCharger) {
984
901
  const dcRw = DC_CHARGER_WRITE_REGISTERS.filter(r => r.perm === 'RW');
985
902
  if (dcRw.length > 0) {
986
- for (const group of this._buildReadGroups(dcRw, 50)) {
987
- if (this._stopped) {
988
- return;
989
- }
990
- try {
991
- const raw = await this.modbus.readHoldingRegisters(inverterId, group.startAddr, group.totalQty);
992
- await this._processReadGroup(group, raw, 'control');
993
- await this._sleep(100);
994
- } catch (err) {
995
- this.log.debug(`[controlRegs] dcCharger addr=${group.startAddr}: ${err.message}`);
996
- }
997
- }
903
+ await this._readRegisterGroups('control.dcCharger', inverterId, dcRw, 50, true);
998
904
  }
999
905
  }
1000
906
  }
@@ -1015,73 +921,22 @@ class Sigenergy extends utils.Adapter {
1015
921
  return;
1016
922
  }
1017
923
  const registers = getRegistersForDevice(device.slaveId);
1018
- const batchSize = 30;
1019
- const groups = this._buildReadGroups(registers, batchSize);
1020
924
  this.log.debug(
1021
- `[SigenMicro] device slaveId=${device.slaveId}` +
1022
- ` model='${device.model}': ${groups.length} group(s), ${registers.length} registers`,
925
+ `[SigenMicro] device slaveId=${device.slaveId} model='${device.model}': ${registers.length} registers`,
1023
926
  );
1024
-
1025
- for (const group of groups) {
1026
- if (this._stopped) {
1027
- return;
1028
- }
1029
- try {
1030
- const raw = await this.modbus.readInputRegisters(device.slaveId, group.startAddr, group.totalQty);
1031
- await this._processReadGroup(group, raw, `sigenmicro.${device.slaveId}`);
1032
- await this._sleep(100);
1033
- } catch (err) {
1034
- this.log.warn(`SigenMicro ID ${device.slaveId} read error at ${group.startAddr}: ${err.message}`);
1035
- }
1036
- }
927
+ await this._readRegisterGroups(`sigenmicro.${device.slaveId}`, device.slaveId, registers, 30);
1037
928
  }
1038
929
  }
1039
930
 
1040
931
  /**
1041
- * Build optimized sequential read groups
932
+ * Build optimized sequential read groups (see lib/readGroups.js)
1042
933
  *
1043
934
  * @param {object[]} registers - Register definitions
1044
935
  * @param {number} maxQty - Maximum registers per group
1045
936
  * @returns {object[]} Grouped register batches
1046
937
  */
1047
938
  _buildReadGroups(registers, maxQty) {
1048
- if (!registers || registers.length === 0) {
1049
- return [];
1050
- }
1051
-
1052
- const sorted = [...registers].sort((a, b) => a.addr - b.addr);
1053
- const groups = [];
1054
- let currentGroup = null;
1055
-
1056
- for (const reg of sorted) {
1057
- if (!currentGroup) {
1058
- currentGroup = {
1059
- startAddr: reg.addr,
1060
- totalQty: reg.qty,
1061
- registers: [{ ...reg, offset: 0 }],
1062
- };
1063
- } else {
1064
- const expectedNext = currentGroup.startAddr + currentGroup.totalQty;
1065
- const gap = reg.addr - expectedNext;
1066
-
1067
- if (gap >= 0 && gap <= 4 && currentGroup.totalQty + gap + reg.qty <= maxQty) {
1068
- const offset = reg.addr - currentGroup.startAddr;
1069
- currentGroup.totalQty = Math.max(currentGroup.totalQty, offset + reg.qty);
1070
- currentGroup.registers.push({ ...reg, offset });
1071
- } else {
1072
- groups.push(currentGroup);
1073
- currentGroup = {
1074
- startAddr: reg.addr,
1075
- totalQty: reg.qty,
1076
- registers: [{ ...reg, offset: 0 }],
1077
- };
1078
- }
1079
- }
1080
- }
1081
- if (currentGroup) {
1082
- groups.push(currentGroup);
1083
- }
1084
- return groups;
939
+ return buildReadGroups(registers, maxQty);
1085
940
  }
1086
941
 
1087
942
  /**
@@ -1128,6 +983,7 @@ class Sigenergy extends utils.Adapter {
1128
983
  'plant.essSoc': 'soc',
1129
984
  'plant.essRatedEnergyCapacity': 'ratedCapacity',
1130
985
  'plant.essDischargeSOC': 'cutoffSoc',
986
+ 'plant.emsWorkMode': 'emsWorkMode',
1131
987
  'inverter.pv1Voltage': 'pv1Voltage',
1132
988
  'inverter.pv1Current': 'pv1Current',
1133
989
  'inverter.pv2Voltage': 'pv2Voltage',
@@ -1141,15 +997,16 @@ class Sigenergy extends utils.Adapter {
1141
997
  }
1142
998
 
1143
999
  /**
1144
- * Format a minute value as an "H:MMh" string (e.g. 83 -> "1:23h").
1145
- * Hours are not capped at 24. Distinguishes three cases so the caller's
1146
- * null-vs-undefined write semantics (see _updateStatistics) are preserved:
1000
+ * Format a minute value as an "H:MM" string (e.g. 83 -> "1:23").
1001
+ * Hours are not capped at 24.
1002
+ * Distinguishes three cases so the caller's null-vs-undefined write
1003
+ * semantics (see _updateStatistics) are preserved:
1147
1004
  * - undefined (statistic disabled via config) -> undefined (skip write)
1148
1005
  * - null / NaN (statistic enabled but currently N/A) -> null (clears stale value)
1149
- * - number -> formatted "H:MMh" string
1006
+ * - number -> formatted "H:MM" string
1150
1007
  *
1151
1008
  * @param {number|null|undefined} totalMinutes - Duration in minutes, or null/undefined per the cases above
1152
- * @returns {string|null|undefined} Formatted "H:MMh" string, or null/undefined mirroring the input case
1009
+ * @returns {string|null|undefined} Formatted "H:MM" string, or null/undefined mirroring the input case
1153
1010
  */
1154
1011
  _formatMinutesAsHM(totalMinutes) {
1155
1012
  if (totalMinutes === undefined) {
@@ -1162,7 +1019,26 @@ class Sigenergy extends utils.Adapter {
1162
1019
  const abs = Math.round(Math.abs(totalMinutes));
1163
1020
  const hours = Math.floor(abs / 60);
1164
1021
  const minutes = abs % 60;
1165
- return `${sign}${hours}:${String(minutes).padStart(2, '0')}h`;
1022
+ return `${sign}${hours}:${String(minutes).padStart(2, '0')}`;
1023
+ }
1024
+
1025
+ /**
1026
+ * Look up the plain-text label for the numeric EMS work mode (register
1027
+ * 30003 / plant.emsWorkMode), using the same EMS_WORK_MODES map already
1028
+ * used for the `common.states` metadata on plant.emsWorkMode. Unlike that
1029
+ * metadata (only understood by UIs/tools that render `common.states`),
1030
+ * this writes the human-readable text directly as the state value, so any
1031
+ * consumer (VIS, Node-RED, external scripts) can use it without needing
1032
+ * the lookup table.
1033
+ *
1034
+ * @param {number|undefined} mode - Raw value of plant.emsWorkMode
1035
+ * @returns {string|undefined} Plain-text label, or undefined if mode hasn't been read yet
1036
+ */
1037
+ _formatEmsWorkMode(mode) {
1038
+ if (mode === undefined) {
1039
+ return undefined;
1040
+ }
1041
+ return EMS_WORK_MODES[mode] || `Unknown (${mode})`;
1166
1042
  }
1167
1043
 
1168
1044
  /**
@@ -1193,6 +1069,7 @@ class Sigenergy extends utils.Adapter {
1193
1069
  'statistics.housePower': statsValues.housePower,
1194
1070
  'statistics.currentSoc': statsValues.currentSoc,
1195
1071
  'statistics.currentPvPower': statsValues.currentPvPower,
1072
+ 'statistics.emsWorkMode': this._formatEmsWorkMode(this._currentData.emsWorkMode),
1196
1073
  'statistics.dayMaxSoc': statsValues.dayMaxSoc,
1197
1074
  'statistics.dayMinSoc': statsValues.dayMinSoc,
1198
1075
  };
@@ -1674,7 +1551,7 @@ class Sigenergy extends utils.Adapter {
1674
1551
  id: 'statistics.batteryTimeToFullHM',
1675
1552
  name: 'Time until battery is fully charged (h:mm)',
1676
1553
  type: 'string',
1677
- unit: '',
1554
+ unit: 'h',
1678
1555
  role: 'text',
1679
1556
  },
1680
1557
  {
@@ -1688,7 +1565,7 @@ class Sigenergy extends utils.Adapter {
1688
1565
  id: 'statistics.batteryTimeRemainingHM',
1689
1566
  name: 'Battery time remaining at current load (h:mm)',
1690
1567
  type: 'string',
1691
- unit: '',
1568
+ unit: 'h',
1692
1569
  role: 'text',
1693
1570
  },
1694
1571
  {
@@ -1702,7 +1579,7 @@ class Sigenergy extends utils.Adapter {
1702
1579
  id: 'statistics.batteryDailyChargeTimeHM',
1703
1580
  name: 'Today: cumulative time spent charging (h:mm)',
1704
1581
  type: 'string',
1705
- unit: '',
1582
+ unit: 'h',
1706
1583
  role: 'text',
1707
1584
  },
1708
1585
  {
@@ -1716,7 +1593,7 @@ class Sigenergy extends utils.Adapter {
1716
1593
  id: 'statistics.batteryCoverageTodayHM',
1717
1594
  name: 'Today: time battery covered consumption (h:mm)',
1718
1595
  type: 'string',
1719
- unit: '',
1596
+ unit: 'h',
1720
1597
  role: 'text',
1721
1598
  },
1722
1599
  {
@@ -1782,6 +1659,13 @@ class Sigenergy extends utils.Adapter {
1782
1659
  unit: 'kW',
1783
1660
  role: 'value.power',
1784
1661
  },
1662
+ {
1663
+ id: 'statistics.emsWorkMode',
1664
+ name: 'EMS work mode (plain text)',
1665
+ type: 'string',
1666
+ unit: '',
1667
+ role: 'text',
1668
+ },
1785
1669
  {
1786
1670
  id: 'statistics.dayMaxSoc',
1787
1671
  name: 'Today maximum SOC',
@@ -1812,7 +1696,12 @@ class Sigenergy extends utils.Adapter {
1812
1696
  },
1813
1697
  native: {},
1814
1698
  };
1815
- await this.setObjectNotExistsAsync(s.id, statObj);
1699
+ // extendObjectAsync (not setObjectNotExistsAsync) so that changes to
1700
+ // an existing definition (e.g. a corrected unit or role) are applied
1701
+ // on every adapter start, instead of being silently ignored forever
1702
+ // once the object has been created once. The state's current value
1703
+ // is untouched by this — only the object's common metadata is merged.
1704
+ await this.extendObjectAsync(s.id, statObj);
1816
1705
  }
1817
1706
  }
1818
1707
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.sigenergy",
3
- "version": "3.1.2",
3
+ "version": "3.3.0",
4
4
  "description": "ioBroker adapter for Sigenergy solar energy systems via Modbus TCP/RTU",
5
5
  "author": {
6
6
  "name": "ssbingo",
@@ -30,12 +30,12 @@
30
30
  "devDependencies": {
31
31
  "@alcalzone/release-script": "^5.2.1",
32
32
  "@alcalzone/release-script-plugin-iobroker": "^5.2.0",
33
- "@alcalzone/release-script-plugin-license": "^5.2.0",
33
+ "@alcalzone/release-script-plugin-license": "^5.2.2",
34
34
  "@alcalzone/release-script-plugin-manual-review": "^5.2.0",
35
35
  "@iobroker/adapter-dev": "^1.5.0",
36
36
  "@iobroker/eslint-config": "^2.3.4",
37
37
  "@iobroker/testing": "^5.3.0",
38
- "@tsconfig/node22": "^22.0.5",
38
+ "@tsconfig/node22": "^22.0.6",
39
39
  "@types/node": "^22.20.1"
40
40
  },
41
41
  "main": "main.js",