iobroker.openknx 1.1.5 → 1.1.6

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
@@ -52,7 +52,7 @@ ioBroker adapter for KNX IP communication, powered by [KNXUltimate](https://gith
52
52
  Placeholder for the next version (at the beginning of the line):
53
53
  ### **WORK IN PROGRESS**
54
54
  -->
55
- ### 1.1.5 (2026-03-31)
55
+ ### 1.1.6 (2026-04-12)
56
56
 
57
57
  - (TA2k) **breaking:** KNX communication switched to KNXUltimate
58
58
  - (TA2k) **breaking:** DPT21 property names changed (outofservice → outOfService, inalarm → inAlarm, alarmeunack → alarmUnAck), values must be boolean
@@ -310,48 +310,71 @@
310
310
  reader.addEventListener("load", function readFile(event) {
311
311
  $("#etsFile").replaceWith($(etsFile));
312
312
  $("#etsFile").change(processXmlFile);
313
- var base64 = btoa(
314
- new Uint8Array(event.target.result).reduce(function (data, byte) {
315
- return data + String.fromCharCode(byte);
316
- }, ""),
317
- );
318
- var sizeMB = (event.target.result.byteLength / 1024 / 1024).toFixed(1);
319
- var estSec = Math.max(1, Math.round(sizeMB * 1));
313
+ // readAsDataURL returns "data:application/...;base64,XXXX"
314
+ var dataUrl = event.target.result;
315
+ var base64 = dataUrl.substring(dataUrl.indexOf(",") + 1);
316
+ var sizeMB = (base64.length * 3 / 4 / 1024 / 1024).toFixed(1);
317
+ console.log("[openknx] knxproj file read: " + sizeMB + " MB");
318
+ var estSec = Math.max(1, Math.round(sizeMB * 0.75));
320
319
  showToast(null, _("Import started") + " (" + sizeMB + " MB, ~" + estSec + "s) ...", null, estSec * 1000 + 3000);
321
320
  $("#progress").show();
322
- sendTo(
323
- null,
324
- "importKnxproj",
325
- {
326
- knxprojBase64: base64,
327
- password: $("#knxprojPassword").val() || undefined,
328
- language: undefined,
329
- onlyAddNewObjects: $("#onlyAddNewObjects").prop("checked"),
330
- removeUnusedObjects: $("#removeUnusedObjects").prop("checked"),
331
- cleanImport: $("#cleanImport").prop("checked"),
332
- },
333
- function (result) {
334
- $("#progress").hide();
335
- if (!result || result.error) {
336
- var message = "Unknown error";
337
- if (result && result.error) {
338
- message = "Imported " + result.count + " states successfully<p>" + result.error + "</p><p>See log for more information</p>";
339
- }
340
- showMessage(_(message));
341
- } else {
342
- showMessage(
343
- _("Imported %s states successfully", result.count) +
344
- "<br/><br/>" +
345
- _("knxproj import hint"),
346
- );
347
- sendTo(null, "restart", null);
348
- showToast(null, _("Restarting adapter" + "..."), null, 10000);
349
- }
350
- window.isProcessingRequest = false;
351
- },
352
- );
321
+
322
+ // Split base64 into 500KB chunks to avoid socket.io message size limits
323
+ var CHUNK_SIZE = 500000;
324
+ var totalChunks = Math.ceil(base64.length / CHUNK_SIZE);
325
+ console.log("[openknx] sending " + totalChunks + " chunks...");
326
+
327
+ var sendChunk = function (index) {
328
+ if (index >= totalChunks) {
329
+ // All chunks sent, trigger import
330
+ console.log("[openknx] all chunks sent, triggering import...");
331
+ sendTo(
332
+ null,
333
+ "importKnxprojEnd",
334
+ {
335
+ password: $("#knxprojPassword").val() || undefined,
336
+ language: undefined,
337
+ onlyAddNewObjects: $("#onlyAddNewObjects").prop("checked"),
338
+ removeUnusedObjects: $("#removeUnusedObjects").prop("checked"),
339
+ cleanImport: $("#cleanImport").prop("checked"),
340
+ },
341
+ function (result) {
342
+ console.log("[openknx] importKnxproj callback:", JSON.stringify(result));
343
+ $("#progress").hide();
344
+ if (!result || result.error) {
345
+ var message = "Unknown error";
346
+ if (result && result.error) {
347
+ message = "Imported " + result.count + " states successfully<p>" + result.error + "</p><p>See log for more information</p>";
348
+ }
349
+ showMessage(_(message));
350
+ } else {
351
+ showMessage(
352
+ _("Imported %s states successfully", result.count) +
353
+ "<br/><br/>" +
354
+ _("knxproj import hint"),
355
+ );
356
+ sendTo(null, "restart", null);
357
+ showToast(null, _("Restarting adapter" + "..."), null, 10000);
358
+ }
359
+ window.isProcessingRequest = false;
360
+ },
361
+ );
362
+ return;
363
+ }
364
+ var chunk = base64.substring(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE);
365
+ sendTo(null, "importKnxprojChunk", { index: index, data: chunk }, function () {
366
+ console.log("[openknx] chunk " + (index + 1) + "/" + totalChunks + " sent");
367
+ sendChunk(index + 1);
368
+ });
369
+ };
370
+
371
+ // Start: tell adapter how many chunks to expect
372
+ sendTo(null, "importKnxprojStart", { totalChunks: totalChunks, sizeMB: sizeMB }, function () {
373
+ console.log("[openknx] import started, sending chunks...");
374
+ sendChunk(0);
375
+ });
353
376
  });
354
- reader.readAsArrayBuffer(file);
377
+ reader.readAsDataURL(file);
355
378
  } else {
356
379
  showMessage(_("Unsupported file format"));
357
380
  }
package/io-package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "openknx",
4
- "version": "1.1.5",
4
+ "version": "1.1.6",
5
5
  "news": {
6
- "1.1.5": {
6
+ "1.1.6": {
7
7
  "en": "**breaking:** KNX communication switched to KNXUltimate\n**breaking:** DPT21 property names changed (outofservice → outOfService, inalarm → inAlarm, alarmeunack → alarmUnAck), values must be boolean\n**breaking:** DPT237 property names changed to camelCase\nfeature: KNX Secure support\nfeature: Native .knxproj import (ETS4/5/6, password-protected) with flags, DPT inference, room assignment\nfeature: Extended DPT coverage (9 additional DPTs, including DPT-22, 213, 222, 235, 242, 249, 251)\nfeature: Improved connection stability\nfeature: Improved role detection (switch, level, value, text, date) based on DPT type\nfeature: Direct Link all iobroker states to a KNX state\nfeature: GA-Tools: all GA properties editable (DPT, type, role, flags) with compact layout",
8
8
  "de": "**breaking:** KNX-Kommunikation auf KNXUltimate umgestellt\n**breaking:** DPT21 Eigenschaftsnamen geändert (outofservice → outOfService, inalarm → inAlarm, alarmeunack → alarmUnAck), Werte müssen boolean sein\n**breaking:** DPT237 Eigenschaftsnamen auf camelCase geändert\nFeature: KNX Secure Unterstützung\nFeature: Nativer .knxproj Import (ETS4/5/6, passwortgeschützt) mit Flags, DPT-Erkennung, Raumzuordnung\nFeature: Erweiterte DPT-Abdeckung (9 zusätzliche DPTs, u.a. DPT-22, 213, 222, 235, 242, 249, 251)\nFeature: Verbesserte Verbindungsstabilität\nFeature: Verbesserte Rollenerkennung (switch, level, value, text, date) basierend auf DPT-Typ\nFeature: Direct Link – beliebige ioBroker-States mit KNX-Gruppenadressen verknüpfen\nFeature: GA-Tools: alle GA-Eigenschaften editierbar (DPT, Typ, Rolle, Flags) mit kompaktem Layout",
9
9
  "ru": "**breaking:** KNX-коммуникация переключена на KNXUltimate\n**breaking:** Имена свойств DPT21 изменены (outofservice → outOfService, inalarm → inAlarm, alarmeunack → alarmUnAck), значения должны быть boolean\n**breaking:** Имена свойств DPT237 изменены на camelCase\nFeature: Поддержка KNX Secure\nFeature: Нативный импорт .knxproj (ETS4/5/6, с паролем) с флагами, определением DPT, назначением комнат\nFeature: Расширенное покрытие DPT (9 дополнительных DPT, включая DPT-22, 213, 222, 235, 242, 249, 251)\nFeature: Улучшенная стабильность соединения\nFeature: Улучшенное определение ролей (switch, level, value, text, date) на основе типа DPT\nFeature: Direct Link – привязка любых состояний ioBroker к групповым адресам KNX\nFeature: GA-Tools: все свойства GA редактируемы (DPT, тип, роль, флаги) с компактным макетом",
@@ -5,104 +5,26 @@
5
5
  * Port of xknxproject/loader/application_program_loader.py
6
6
  * Parses M-xxxx/<application_program>.xml files to extract ComObjects,
7
7
  * ComObjectRefs, Allocators, ModuleDef Arguments, Channels, and Translations.
8
+ *
9
+ * Uses SAX streaming parser instead of DOM to handle very large XMLs (100+ MB)
10
+ * without excessive memory consumption.
8
11
  */
9
12
 
10
13
  "use strict";
11
14
 
12
- const { DOMParser } = require("@xmldom/xmldom");
15
+ const sax = require("sax");
13
16
  const { parseDptTypes, parseXmlFlag } = require("./util");
14
17
 
15
18
  /**
16
- * Get all descendant elements with a given local name (namespace-agnostic).
17
- *
18
- * @param {Node} node
19
- * @param {string} localName
20
- * @returns {Element[]}
21
- */
22
- function byTagNS(node, localName) {
23
- return Array.from(node.getElementsByTagNameNS("*", localName));
24
- }
25
-
26
- /**
27
- * Get direct child elements with a given local name.
28
- *
29
- * @param {Node} node
30
- * @param {string} localName
31
- * @returns {Element[]}
32
- */
33
- function childrenByTag(node, localName) {
34
- const result = [];
35
- const children = node.childNodes;
36
- for (let i = 0; i < children.length; i++) {
37
- const child = children[i];
38
- if (child.nodeType === 1) {
39
- const ln = child.localName || child.nodeName.replace(/^.*:/, "");
40
- if (ln === localName) {
41
- result.push(child);
42
- }
43
- }
44
- }
45
- return result;
46
- }
47
-
48
- /**
49
- * Parse a ComObject element.
50
- *
51
- * @param {Element} elem
52
- * @returns {object} ComObject
53
- */
54
- function parseComObject(elem) {
55
- const identifier = elem.getAttribute("Id");
56
- return {
57
- identifier,
58
- name: elem.getAttribute("Name") || "",
59
- text: elem.getAttribute("Text") || "",
60
- number: parseInt(elem.getAttribute("Number") || "0", 10),
61
- functionText: elem.getAttribute("FunctionText") || "",
62
- objectSize: elem.getAttribute("ObjectSize") || "",
63
- readFlag: parseXmlFlag(elem.getAttribute("ReadFlag"), false),
64
- writeFlag: parseXmlFlag(elem.getAttribute("WriteFlag"), false),
65
- communicationFlag: parseXmlFlag(elem.getAttribute("CommunicationFlag"), false),
66
- transmitFlag: parseXmlFlag(elem.getAttribute("TransmitFlag"), false),
67
- updateFlag: parseXmlFlag(elem.getAttribute("UpdateFlag"), false),
68
- readOnInitFlag: parseXmlFlag(elem.getAttribute("ReadOnInitFlag"), false),
69
- datapointTypes: parseDptTypes(elem.getAttribute("DatapointType")),
70
- baseNumberArgumentRef: elem.getAttribute("BaseNumber") || null,
71
- };
72
- }
73
-
74
- /**
75
- * Parse a ComObjectRef element.
76
- *
77
- * @param {Element} elem
78
- * @returns {object} ComObjectRef
19
+ * Strip namespace prefix from a tag name: "knx:ComObject" -> "ComObject"
79
20
  */
80
- function parseComObjectRef(elem) {
81
- const identifier = elem.getAttribute("Id");
82
- return {
83
- identifier,
84
- refId: elem.getAttribute("RefId") || "",
85
- name: elem.getAttribute("Name") || null,
86
- text: elem.getAttribute("Text") || null,
87
- functionText: elem.getAttribute("FunctionText") || null,
88
- objectSize: elem.getAttribute("ObjectSize") || null,
89
- readFlag: parseXmlFlag(elem.getAttribute("ReadFlag"), null),
90
- writeFlag: parseXmlFlag(elem.getAttribute("WriteFlag"), null),
91
- communicationFlag: parseXmlFlag(elem.getAttribute("CommunicationFlag"), null),
92
- transmitFlag: parseXmlFlag(elem.getAttribute("TransmitFlag"), null),
93
- updateFlag: parseXmlFlag(elem.getAttribute("UpdateFlag"), null),
94
- readOnInitFlag: parseXmlFlag(elem.getAttribute("ReadOnInitFlag"), null),
95
- datapointTypes: parseDptTypes(elem.getAttribute("DatapointType")),
96
- textParameterRefId: elem.getAttribute("TextParameterRefId") || null,
97
- };
21
+ function localName(name) {
22
+ const i = name.indexOf(":");
23
+ return i >= 0 ? name.substring(i + 1) : name;
98
24
  }
99
25
 
100
26
  /**
101
27
  * Apply translations to translatable objects (ComObjects, ComObjectRefs, Channels).
102
- * Modifies objects in place.
103
- *
104
- * @param {object} objectMap - { [id]: object }
105
- * @param {object} translationMap - { [refId]: { [attributeName]: text } }
106
28
  */
107
29
  function applyTranslations(objectMap, translationMap) {
108
30
  for (const identifier of Object.keys(objectMap)) {
@@ -121,182 +43,208 @@ function applyTranslations(objectMap, translationMap) {
121
43
  }
122
44
 
123
45
  /**
124
- * Parse translations from the Languages section of the XML document.
125
- *
126
- * @param {Document} doc - Parsed XML document
127
- * @param {object} comObjects
128
- * @param {object} comObjectRefs
129
- * @param {Set<string>} usedComObjectRefIds
130
- * @param {object} channels
131
- * @param {string} languageCode
132
- */
133
- function parseTranslations(doc, comObjects, comObjectRefs, usedComObjectRefIds, channels, languageCode) {
134
- // Build set of IDs we need translations for
135
- const usedComObjectIds = new Set();
136
- for (const ref of Object.values(comObjectRefs)) {
137
- usedComObjectIds.add(ref.refId);
138
- }
139
- const usedTranslationIds = new Set([...usedComObjectIds, ...usedComObjectRefIds, ...Object.keys(channels)]);
140
-
141
- // translationMap: { refId: { attributeName: text } }
142
- const translationMap = {};
143
-
144
- // Find the Language element matching our languageCode
145
- const languageNodes = byTagNS(doc, "Language");
146
- let targetLanguageNode = null;
147
- for (const langNode of languageNodes) {
148
- if (langNode.getAttribute && langNode.getAttribute("Identifier") === languageCode) {
149
- targetLanguageNode = langNode;
150
- break;
151
- }
152
- }
153
-
154
- if (!targetLanguageNode) {
155
- return;
156
- }
157
-
158
- // Find TranslationElement descendants within this Language node
159
- const translationElements = byTagNS(targetLanguageNode, "TranslationElement");
160
-
161
- for (const transElem of translationElements) {
162
- const refId = transElem.getAttribute("RefId");
163
- if (!refId || !usedTranslationIds.has(refId)) {
164
- continue;
165
- }
166
-
167
- // Find Translation direct children
168
- const translations = childrenByTag(transElem, "Translation");
169
-
170
- for (const trans of translations) {
171
- const attributeName = trans.getAttribute("AttributeName");
172
- const text = trans.getAttribute("Text");
173
- if (attributeName && text) {
174
- if (!translationMap[refId]) {
175
- translationMap[refId] = {};
176
- }
177
- translationMap[refId][attributeName] = text;
178
- }
179
- }
180
- }
181
-
182
- applyTranslations(comObjectRefs, translationMap);
183
- applyTranslations(comObjects, translationMap);
184
- applyTranslations(channels, translationMap);
185
- }
186
-
187
- /**
188
- * Load application program data from an XML file inside the KNX project archive.
46
+ * Load application program data from an XML file using SAX streaming.
189
47
  *
190
48
  * @param {import('./extractor').KNXProjContents} knxProjContents
191
49
  * @param {Set<string>} usedComObjectRefIds - Only load ComObjectRefs with these IDs
192
- * @param {string} applicationProgramXmlPath - Path like "M-0083/M-0083_A-00B0-32-0DFC.xml"
50
+ * @param {string} applicationProgramXmlPath
193
51
  * @param {string} languageCode
194
52
  * @returns {Promise<object>} ApplicationProgram
195
53
  */
196
54
  async function load(knxProjContents, usedComObjectRefIds, applicationProgramXmlPath, languageCode) {
197
55
  let xmlContent = await knxProjContents.readFile(applicationProgramXmlPath);
198
56
 
199
- const comObjects = {}; // { Id: ComObject }
200
- const comObjectRefs = {}; // { Id: ComObjectRef }
201
- const allocators = {}; // { Id: Allocator }
202
- const moduleDefArguments = {}; // { Id: { name, allocates } }
203
- const numericArgs = {}; // { RefId: { allocatorRefId, value, baseValue } }
204
- const channels = {}; // { Id: ApplicationProgramChannel }
205
-
206
- // Strip BOM if present - @xmldom/xmldom fails if BOM precedes <?xml declaration
57
+ // Strip BOM if present
207
58
  if (xmlContent.charCodeAt(0) === 0xfeff) {
208
59
  xmlContent = xmlContent.slice(1);
209
60
  }
210
61
 
211
- const parser = new DOMParser();
212
- const doc = parser.parseFromString(xmlContent, "text/xml");
62
+ const comObjects = {};
63
+ const comObjectRefs = {};
64
+ const allocators = {};
65
+ const moduleDefArguments = {};
66
+ const numericArgs = {};
67
+ const channels = {};
213
68
 
214
- // --- ComObject elements ---
215
- const comObjectNodes = byTagNS(doc, "ComObject");
216
- for (const elem of comObjectNodes) {
217
- const co = parseComObject(elem);
218
- comObjects[co.identifier] = co;
219
- }
69
+ // Translation state
70
+ const usedTranslationIds = new Set();
71
+ const translationMap = {};
72
+ let inTargetLanguage = false;
73
+ let currentTranslationRefId = null;
220
74
 
221
- // --- ComObjectRef elements (filtered to usedComObjectRefIds) ---
222
- const comObjectRefNodes = byTagNS(doc, "ComObjectRef");
223
- for (const elem of comObjectRefNodes) {
224
- const id = elem.getAttribute("Id");
225
- if (usedComObjectRefIds.has(id)) {
226
- const cor = parseComObjectRef(elem);
227
- comObjectRefs[id] = cor;
228
- }
229
- }
75
+ // Track parent context for ModuleDef > Argument
76
+ let inModuleDef = false;
230
77
 
231
- // --- Allocator elements ---
232
- const allocatorNodes = byTagNS(doc, "Allocator");
233
- for (const elem of allocatorNodes) {
234
- const id = elem.getAttribute("Id");
235
- allocators[id] = {
236
- identifier: id,
237
- name: elem.getAttribute("Name") || "",
238
- start: parseInt(elem.getAttribute("Start") || "0", 10),
239
- end: parseInt(elem.getAttribute("maxInclusive") || "0", 10),
78
+ return new Promise((resolve, reject) => {
79
+ const parser = sax.parser(true, { trim: false, xmlns: false });
80
+
81
+ parser.onerror = (err) => {
82
+ // sax recovers from most errors, just log
83
+ parser.resume();
240
84
  };
241
- }
242
85
 
243
- // --- ModuleDef Arguments ---
244
- // Find all Argument elements that are descendants of ModuleDef elements
245
- const moduleDefNodes = byTagNS(doc, "ModuleDef");
246
- for (const modDef of moduleDefNodes) {
247
- const argumentNodes = byTagNS(modDef, "Argument");
248
- for (const elem of argumentNodes) {
249
- const id = elem.getAttribute("Id");
250
- const allocatesAttr = elem.getAttribute("Allocates");
251
- moduleDefArguments[id] = {
252
- name: elem.getAttribute("Name") || "",
253
- allocates: allocatesAttr != null ? parseInt(allocatesAttr, 10) : null,
254
- };
255
- }
256
- }
86
+ parser.onopentag = (node) => {
87
+ const tag = localName(node.name);
88
+ const a = node.attributes;
89
+
90
+ switch (tag) {
91
+ case "ComObject": {
92
+ const id = a.Id;
93
+ if (id) {
94
+ comObjects[id] = {
95
+ identifier: id,
96
+ name: a.Name || "",
97
+ text: a.Text || "",
98
+ number: parseInt(a.Number || "0", 10),
99
+ functionText: a.FunctionText || "",
100
+ objectSize: a.ObjectSize || "",
101
+ readFlag: parseXmlFlag(a.ReadFlag, false),
102
+ writeFlag: parseXmlFlag(a.WriteFlag, false),
103
+ communicationFlag: parseXmlFlag(a.CommunicationFlag, false),
104
+ transmitFlag: parseXmlFlag(a.TransmitFlag, false),
105
+ updateFlag: parseXmlFlag(a.UpdateFlag, false),
106
+ readOnInitFlag: parseXmlFlag(a.ReadOnInitFlag, false),
107
+ datapointTypes: parseDptTypes(a.DatapointType),
108
+ baseNumberArgumentRef: a.BaseNumber || null,
109
+ };
110
+ }
111
+ break;
112
+ }
113
+ case "ComObjectRef": {
114
+ const id = a.Id;
115
+ if (id && usedComObjectRefIds.has(id)) {
116
+ comObjectRefs[id] = {
117
+ identifier: id,
118
+ refId: a.RefId || "",
119
+ name: a.Name || null,
120
+ text: a.Text || null,
121
+ functionText: a.FunctionText || null,
122
+ objectSize: a.ObjectSize || null,
123
+ readFlag: parseXmlFlag(a.ReadFlag, null),
124
+ writeFlag: parseXmlFlag(a.WriteFlag, null),
125
+ communicationFlag: parseXmlFlag(a.CommunicationFlag, null),
126
+ transmitFlag: parseXmlFlag(a.TransmitFlag, null),
127
+ updateFlag: parseXmlFlag(a.UpdateFlag, null),
128
+ readOnInitFlag: parseXmlFlag(a.ReadOnInitFlag, null),
129
+ datapointTypes: parseDptTypes(a.DatapointType),
130
+ textParameterRefId: a.TextParameterRefId || null,
131
+ };
132
+ }
133
+ break;
134
+ }
135
+ case "Allocator": {
136
+ const id = a.Id;
137
+ if (id) {
138
+ allocators[id] = {
139
+ identifier: id,
140
+ name: a.Name || "",
141
+ start: parseInt(a.Start || "0", 10),
142
+ end: parseInt(a.maxInclusive || "0", 10),
143
+ };
144
+ }
145
+ break;
146
+ }
147
+ case "ModuleDef":
148
+ inModuleDef = true;
149
+ break;
150
+ case "Argument": {
151
+ if (inModuleDef) {
152
+ const id = a.Id;
153
+ if (id) {
154
+ moduleDefArguments[id] = {
155
+ name: a.Name || "",
156
+ allocates: a.Allocates != null ? parseInt(a.Allocates, 10) : null,
157
+ };
158
+ }
159
+ }
160
+ break;
161
+ }
162
+ case "NumericArg": {
163
+ const refId = a.RefId;
164
+ if (refId) {
165
+ numericArgs[refId] = {
166
+ allocatorRefId: a.AllocatorRefId || null,
167
+ value: a.Value != null ? parseInt(a.Value, 10) : null,
168
+ baseValue: a.BaseValue || null,
169
+ };
170
+ }
171
+ break;
172
+ }
173
+ case "Channel": {
174
+ const id = a.Id;
175
+ if (id) {
176
+ channels[id] = {
177
+ identifier: id,
178
+ name: a.Name || "",
179
+ number: a.Number || "",
180
+ text: a.Text || null,
181
+ textParameterRefId: a.TextParameterRefId || null,
182
+ };
183
+ }
184
+ break;
185
+ }
186
+ // Translation handling
187
+ case "Language":
188
+ if (languageCode && a.Identifier === languageCode) {
189
+ inTargetLanguage = true;
190
+ }
191
+ break;
192
+ case "TranslationElement":
193
+ if (inTargetLanguage && a.RefId) {
194
+ currentTranslationRefId = a.RefId;
195
+ }
196
+ break;
197
+ case "Translation":
198
+ if (currentTranslationRefId && a.AttributeName && a.Text) {
199
+ if (!translationMap[currentTranslationRefId]) {
200
+ translationMap[currentTranslationRefId] = {};
201
+ }
202
+ translationMap[currentTranslationRefId][a.AttributeName] = a.Text;
203
+ }
204
+ break;
205
+ }
206
+ };
257
207
 
258
- // --- NumericArg elements (from ModuleDef choose/dynamic blocks) ---
259
- const numericArgNodes = byTagNS(doc, "NumericArg");
260
- for (const elem of numericArgNodes) {
261
- const refId = elem.getAttribute("RefId");
262
- if (refId) {
263
- const valueAttr = elem.getAttribute("Value");
264
- numericArgs[refId] = {
265
- allocatorRefId: elem.getAttribute("AllocatorRefId") || null,
266
- value: valueAttr != null ? parseInt(valueAttr, 10) : null,
267
- baseValue: elem.getAttribute("BaseValue") || null,
268
- };
269
- }
270
- }
208
+ parser.onclosetag = (name) => {
209
+ const tag = localName(name);
210
+ if (tag === "ModuleDef") {
211
+ inModuleDef = false;
212
+ } else if (tag === "Language") {
213
+ inTargetLanguage = false;
214
+ } else if (tag === "TranslationElement") {
215
+ currentTranslationRefId = null;
216
+ }
217
+ };
271
218
 
272
- // --- Channel elements ---
273
- const channelNodes = byTagNS(doc, "Channel");
274
- for (const elem of channelNodes) {
275
- const id = elem.getAttribute("Id");
276
- if (id) {
277
- channels[id] = {
278
- identifier: id,
279
- name: elem.getAttribute("Name") || "",
280
- number: elem.getAttribute("Number") || "",
281
- text: elem.getAttribute("Text") || null,
282
- textParameterRefId: elem.getAttribute("TextParameterRefId") || null,
283
- };
284
- }
285
- }
219
+ parser.onend = () => {
220
+ // Build translation IDs from parsed data and apply
221
+ if (languageCode) {
222
+ for (const ref of Object.values(comObjectRefs)) {
223
+ usedTranslationIds.add(ref.refId);
224
+ }
225
+ for (const id of usedComObjectRefIds) {
226
+ usedTranslationIds.add(id);
227
+ }
228
+ for (const id of Object.keys(channels)) {
229
+ usedTranslationIds.add(id);
230
+ }
231
+ applyTranslations(comObjectRefs, translationMap);
232
+ applyTranslations(comObjects, translationMap);
233
+ applyTranslations(channels, translationMap);
234
+ }
286
235
 
287
- // --- Translations ---
288
- if (languageCode) {
289
- parseTranslations(doc, comObjects, comObjectRefs, usedComObjectRefIds, channels, languageCode);
290
- }
236
+ resolve({
237
+ comObjects,
238
+ comObjectRefs,
239
+ allocators,
240
+ moduleDefArguments,
241
+ numericArgs,
242
+ channels,
243
+ });
244
+ };
291
245
 
292
- return {
293
- comObjects,
294
- comObjectRefs,
295
- allocators,
296
- moduleDefArguments,
297
- numericArgs,
298
- channels,
299
- };
246
+ parser.write(xmlContent).close();
247
+ });
300
248
  }
301
249
 
302
250
  module.exports = {
@@ -47,33 +47,34 @@ async function openZipBuffer(buffer, password) {
47
47
  throw err;
48
48
  }
49
49
 
50
- // Pre-read all entries into memory
50
+ // Lazy extraction: only decompress entries when buffer() is called.
51
+ // Buffers are NOT cached — each call re-decompresses from the ZIP entry.
52
+ // This frees memory immediately after each file is processed, avoiding
53
+ // hundreds of MB of simultaneous buffer allocations on large projects.
51
54
  const files = [];
52
55
  for (const entry of entries) {
53
56
  if (entry.directory) {
54
57
  continue;
55
58
  }
56
- const filename = entry.filename;
57
- let buf;
58
- try {
59
- const data = await entry.getData(new Uint8ArrayWriter());
60
- buf = Buffer.from(data);
61
- } catch (err) {
62
- if (err.message && (err.message.includes("password") || err.message.includes("Password"))) {
63
- await reader.close().catch(() => {});
64
- throw new InvalidPasswordException(`Failed to decrypt entry "${filename}" - wrong password? (${err.message})`);
65
- }
66
- await reader.close().catch(() => {});
67
- throw err;
68
- }
69
59
  files.push({
70
- path: filename,
60
+ path: entry.filename,
71
61
  type: "File",
72
- uncompressedSize: buf.length,
73
- buffer: async () => buf,
62
+ uncompressedSize: entry.uncompressedSize,
63
+ _entry: entry,
64
+ buffer: async function () {
65
+ try {
66
+ const data = await this._entry.getData(new Uint8ArrayWriter());
67
+ return Buffer.from(data);
68
+ } catch (err) {
69
+ if (err.message && (err.message.includes("password") || err.message.includes("Password"))) {
70
+ throw new InvalidPasswordException(`Failed to decrypt entry "${this.path}" - wrong password? (${err.message})`);
71
+ }
72
+ throw err;
73
+ }
74
+ },
74
75
  });
75
76
  }
76
- await reader.close();
77
+ // Keep reader open — entries are decompressed lazily in buffer()
77
78
 
78
79
  return { files };
79
80
  }
@@ -677,9 +677,10 @@ class Parser {
677
677
  }
678
678
  }
679
679
 
680
- // 5. Group devices by application program, parse each once
680
+ // 5+6. Load each application program, merge into devices immediately,
681
+ // then discard to free memory. Processing one at a time avoids holding
682
+ // multiple large DOM trees simultaneously.
681
683
  const applicationProgramFiles = getApplicationProgramFilesForDevices(this.devices);
682
- const applications = new Map();
683
684
 
684
685
  for (const [xmlFile, devicesForApp] of applicationProgramFiles) {
685
686
  const usedRefIds = collectUsedComObjectRefIds(devicesForApp);
@@ -689,20 +690,12 @@ class Parser {
689
690
  xmlFile,
690
691
  this.languageCode,
691
692
  );
692
- applications.set(xmlFile, appProgram);
693
- }
694
693
 
695
- // 6. Merge application program info into devices
696
- for (const device of this.devices) {
697
- if (!device.applicationProgramRef) {
698
- continue;
699
- }
700
- const xmlFile = applicationProgramXml(device);
701
- const application = applications.get(xmlFile);
702
- if (!application) {
703
- continue;
694
+ // Merge into devices that use this application program
695
+ for (const device of devicesForApp) {
696
+ mergeDeviceApplicationProgramInfo(device, appProgram);
704
697
  }
705
- mergeDeviceApplicationProgramInfo(device, application);
698
+ // appProgram goes out of scope here — DOM tree and parsed data freed
706
699
  }
707
700
  }
708
701
 
package/main.js CHANGED
@@ -200,22 +200,58 @@ class openknx extends utils.Adapter {
200
200
  };
201
201
  if (obj.message.cleanImport) {
202
202
  this.log.info("Clean import: deleting all existing KNX objects before import...");
203
- this.cleanImport().then(doXmlImport);
203
+ this.cleanImport().then(doXmlImport).catch(e => {
204
+ this.log.error(`Clean import failed: ${e.message}`);
205
+ if (obj.callback) {
206
+ this.sendTo(obj.from, obj.command, { error: `Clean import failed: ${e.message}`, count: 0 }, obj.callback);
207
+ }
208
+ });
204
209
  } else {
205
210
  doXmlImport();
206
211
  }
207
212
  break;
208
213
  }
209
- case "importKnxproj": {
210
- this.log.info("ETS .knxproj import...");
214
+ case "importKnxprojStart": {
215
+ this.knxprojChunks = [];
216
+ this.log.info(`ETS .knxproj chunked import started (${obj.message.totalChunks} chunks, ${obj.message.sizeMB} MB)`);
217
+ if (obj.callback) {
218
+ this.sendTo(obj.from, obj.command, {}, obj.callback);
219
+ }
220
+ break;
221
+ }
222
+ case "importKnxprojChunk": {
223
+ // Decode each chunk to Buffer immediately to avoid keeping full base64 string in memory
224
+ this.knxprojChunks[obj.message.index] = Buffer.from(obj.message.data, "base64");
225
+ if (obj.callback) {
226
+ this.sendTo(obj.from, obj.command, {}, obj.callback);
227
+ }
228
+ break;
229
+ }
230
+ case "importKnxprojEnd": {
231
+ this.log.info("ETS .knxproj all chunks received, assembling...");
232
+ const buffer = Buffer.concat(this.knxprojChunks);
233
+ this.knxprojChunks = null;
211
234
  const doKnxprojImport = async () => {
212
235
  try {
213
- const buffer = Buffer.from(obj.message.knxprojBase64, "base64");
236
+ this.log.info(`knxproj file size: ${(buffer.length / 1024 / 1024).toFixed(1)} MB`);
237
+ // Warn if heap limit might be too low for large projects
238
+ const v8 = require("v8");
239
+ const heapStats = v8.getHeapStatistics();
240
+ const heapLimitMB = Math.round(heapStats.heap_size_limit / 1024 / 1024);
241
+ const fileSizeMB = buffer.length / 1024 / 1024;
242
+ if (fileSizeMB > 10 && heapLimitMB < 1024) {
243
+ this.log.warn(`Large knxproj (${fileSizeMB.toFixed(0)} MB) with ${heapLimitMB} MB heap limit. If the adapter crashes with "heap out of memory", increase Node.js memory: Instances > openknx > wrench icon > Node.js Options: --max-old-space-size=2048`);
244
+ }
214
245
  const password = obj.message.password || undefined;
215
246
  const language = obj.message.language || undefined;
247
+ this.log.debug("knxproj: extracting ZIP and parsing XML...");
216
248
  const knxProject = await parseKnxproj(buffer, password, language);
249
+ const gaCount = knxProject.groupAddresses ? Object.keys(knxProject.groupAddresses).length : 0;
250
+ this.log.info(`knxproj: parsed ${gaCount} group addresses`);
217
251
  const res = projectImport.convertKnxProject(this, knxProject);
252
+ this.log.info(`knxproj: converted to ${res.objects.length} ioBroker objects`);
218
253
  await this._createRoomEnums(res.rooms);
254
+ this.log.debug(`knxproj: created ${Object.keys(res.rooms || {}).length} room enums`);
219
255
  this._finishImport(res.objects, res.error, obj);
220
256
  } catch (e) {
221
257
  this.log.error(`knxproj import failed: ${e.message}`);
@@ -235,7 +271,12 @@ class openknx extends utils.Adapter {
235
271
  };
236
272
  if (obj.message.cleanImport) {
237
273
  this.log.info("Clean import: deleting all existing KNX objects before import...");
238
- this.cleanImport().then(doKnxprojImport);
274
+ this.cleanImport().then(doKnxprojImport).catch(e => {
275
+ this.log.error(`Clean import failed: ${e.message}`);
276
+ if (obj.callback) {
277
+ this.sendTo(obj.from, obj.command, { error: `Clean import failed: ${e.message}`, count: 0 }, obj.callback);
278
+ }
279
+ });
239
280
  } else {
240
281
  doKnxprojImport();
241
282
  }
@@ -371,6 +412,7 @@ class openknx extends utils.Adapter {
371
412
  * Common import finish logic shared between XML and knxproj import.
372
413
  */
373
414
  _finishImport(res, parseError, obj) {
415
+ this.log.info(`Importing ${res.length} objects into ioBroker...`);
374
416
  this.updateObjects(res, 0, obj.message.onlyAddNewObjects, (updateError, length) => {
375
417
  const msg = {
376
418
  error:
@@ -426,12 +468,26 @@ class openknx extends utils.Adapter {
426
468
  * delete all existing KNX objects before a clean re-import
427
469
  */
428
470
  async cleanImport() {
429
- await this.delObjectAsync("", { recursive: true });
471
+ try {
472
+ await this.delObjectAsync("", { recursive: true });
473
+ } catch (e) {
474
+ this.log.warn(`delObjectAsync("") failed (${e.message}), falling back to manual deletion`);
475
+ const objects = await this.getAdapterObjectsAsync();
476
+ for (const id of Object.keys(objects)) {
477
+ await this.delObjectAsync(id).catch(() => {});
478
+ }
479
+ }
430
480
  this.log.info("cleanImport: deleted all existing KNX objects");
481
+ // Re-create info objects that are needed during runtime
482
+ await this.setObjectNotExistsAsync("info.busload", { type: "state", common: { role: "info", name: "Busload", type: "number", read: true, write: false, def: 0, unit: "%" }, native: {} });
483
+ await this.setObjectNotExistsAsync("info.messagecount", { type: "state", common: { role: "info", name: "Message count", type: "number", read: true, write: false, def: 0 }, native: {} });
431
484
  }
432
485
 
433
486
  // write found communication objects to adapter object tree
434
487
  updateObjects(objects, i, onlyAddNewObjects, callback) {
488
+ if (i > 0 && i % 100 === 0) {
489
+ this.log.debug(`updateObjects: ${i}/${objects.length} objects written`);
490
+ }
435
491
  if (i >= objects.length) {
436
492
  // end of recursion reached
437
493
  let err = this.warnDuplicates(objects);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.openknx",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "ioBroker knx Adapter",
5
5
  "author": "boellner",
6
6
  "contributors": [
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@iobroker/adapter-core": "^3.3.2",
38
- "@xmldom/xmldom": "^0.9.8",
39
- "@zip.js/zip.js": "^2.8.23",
38
+ "@xmldom/xmldom": "^0.9.9",
39
+ "@zip.js/zip.js": "^2.8.26",
40
40
  "ipaddr.js": "^2.3.0",
41
41
  "knxultimate": "^5.4.0",
42
42
  "xpath": "^0.0.34"
@@ -50,7 +50,7 @@
50
50
  "@iobroker/eslint-config": "^2.2.0",
51
51
  "@iobroker/testing": "^5.2.2",
52
52
  "@tsconfig/node20": "^20.1.9",
53
- "@types/node": "^24.12.0",
53
+ "@types/node": "^25.6.0",
54
54
  "typescript": "~5.9.3"
55
55
  },
56
56
  "main": "main.js",