iobroker.eos-admin 7.9.76 → 7.9.78

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.
@@ -5,71 +5,91 @@
5
5
  if (typeof module === 'object' && module.exports) module.exports = api;
6
6
  if (!root) return;
7
7
 
8
- root.NEXOWATT_EOS_MANUAL_WRITE_POLICY_VERSION = 'v76-universal-manual-write';
8
+ root.NEXOWATT_EOS_MANUAL_WRITE_POLICY_VERSION = 'v76-universal-manual-state-editor';
9
+ root.NEXOWATT_EOS_MANUAL_WRITE_POLICY = api;
9
10
  root.NEXOWATT_EOS_GET_WRITE_BEHAVIOR = api.getWriteBehavior;
11
+ root.NEXOWATT_EOS_GET_DIRECT_WRITE_VALUE = api.getDirectWriteValue;
12
+ root.NEXOWATT_EOS_GET_EXPERT_ONLY_REASON = api.getExpertOnlyReason;
10
13
  root.NEXOWATT_EOS_IS_EXPERT_ONLY_STATE = api.isExpertOnlyState;
11
14
  root.NEXOWATT_EOS_COERCE_BOOLEAN = api.toBoolean;
15
+ root.NEXOWATT_EOS_PREPARE_MANUAL_EDITOR = api.prepareEditor;
16
+ root.NEXOWATT_EOS_PARSE_MANUAL_VALUE = api.parseEditorValue;
12
17
  root.NEXOWATT_EOS_WRITE_MANUAL_STATE = api.writeManualState;
13
- root.NEXOWATT_EOS_RESOLVE_EDITOR_TYPE = api.resolveEditorType;
14
- root.NEXOWATT_EOS_PREPARE_EDITOR_VALUE = api.prepareEditorValue;
15
- root.NEXOWATT_EOS_COERCE_WRITE_VALUE = api.coerceWriteValue;
16
- root.NEXOWATT_EOS_GET_DIRECT_WRITE_VALUE = api.getDirectWriteValue;
17
- root.NEXOWATT_EOS_GET_WRITE_LABEL = api.getWriteLabel;
18
- root.NEXOWATT_EOS_NORMALIZE_STATES = api.normalizeStates;
19
18
  })(typeof window !== 'undefined' ? window : typeof globalThis !== 'undefined' ? globalThis : null, function () {
20
19
  'use strict';
21
20
 
22
- // Manual writes are serialized per datapoint. A second command is queued
23
- // instead of being silently dropped while the previous write is pending.
24
- const writeQueues = new Map();
21
+ const pendingWrites = new Map();
25
22
 
26
23
  const normalizeText = value => String(value == null ? '' : value)
27
24
  .normalize('NFKD')
28
25
  .replace(/[\u0300-\u036f]/g, '')
29
- .toLowerCase()
30
- .trim();
26
+ .toLowerCase();
31
27
 
32
28
  const flattenTranslatedText = value => {
33
29
  if (value == null) return '';
34
30
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
35
- if (typeof value === 'object') return Object.values(value).map(flattenTranslatedText).filter(Boolean).join(' ');
31
+ if (Array.isArray(value)) return value.map(flattenTranslatedText).join(' ');
32
+ if (typeof value === 'object') return Object.values(value).map(flattenTranslatedText).join(' ');
36
33
  return '';
37
34
  };
38
35
 
39
- const getCommon = obj => (obj && obj.common && typeof obj.common === 'object' ? obj.common : {});
40
- const getNative = obj => (obj && obj.native && typeof obj.native === 'object' ? obj.native : {});
36
+ const normalizeCommonType = (obj, value) => {
37
+ const common = obj && obj.common ? obj.common : {};
38
+ const type = Array.isArray(common.type) ? common.type[0] : common.type;
39
+ if (['number', 'string', 'boolean', 'array', 'object', 'mixed', 'file', 'json'].includes(type)) return type;
40
+ if (Array.isArray(value)) return 'array';
41
+ if (value && typeof value === 'object') return 'object';
42
+ if (typeof value === 'number') return 'number';
43
+ if (typeof value === 'boolean') return 'boolean';
44
+ return 'string';
45
+ };
41
46
 
42
- const getExplicitValue = (obj, name) => {
43
- const common = getCommon(obj);
44
- const native = getNative(obj);
45
- const custom = common.custom || {};
46
- const candidates = [
47
- common[name],
48
- native[name],
49
- native.nexowatt && native.nexowatt[name],
50
- custom.nexowatt && custom.nexowatt[name],
51
- custom['nexowatt.eos'] && custom['nexowatt.eos'][name],
52
- custom['eos-admin'] && custom['eos-admin'][name],
53
- ];
54
- for (const value of candidates) {
55
- if (value !== undefined) return value;
47
+ const normalizeStates = states => {
48
+ if (!states) return [];
49
+ if (Array.isArray(states)) return states.map((label, index) => ({ key: String(index), label: String(label) }));
50
+ if (typeof states === 'string') {
51
+ return states
52
+ .split(';')
53
+ .map(part => part.trim())
54
+ .filter(Boolean)
55
+ .map(part => {
56
+ const index = part.indexOf(':');
57
+ return index === -1
58
+ ? { key: part, label: part }
59
+ : { key: part.slice(0, index).trim(), label: part.slice(index + 1).trim() };
60
+ });
56
61
  }
57
- return undefined;
62
+ if (typeof states === 'object') {
63
+ return Object.entries(states).map(([key, label]) => ({ key, label: flattenTranslatedText(label) || key }));
64
+ }
65
+ return [];
58
66
  };
59
67
 
60
68
  const getExplicitExpertOnly = obj => {
61
- const candidates = [getExplicitValue(obj, 'manualWriteExpertOnly'), getExplicitValue(obj, 'expertOnly')];
69
+ const common = obj && obj.common ? obj.common : {};
70
+ const native = obj && obj.native ? obj.native : {};
71
+ const custom = common.custom || {};
72
+ const candidates = [
73
+ common.manualWriteExpertOnly,
74
+ common.expertOnly,
75
+ native.manualWriteExpertOnly,
76
+ native.nexowatt && native.nexowatt.manualWriteExpertOnly,
77
+ custom.nexowatt && custom.nexowatt.manualWriteExpertOnly,
78
+ custom['nexowatt.eos'] && custom['nexowatt.eos'].manualWriteExpertOnly,
79
+ custom['eos-admin'] && custom['eos-admin'].manualWriteExpertOnly,
80
+ ];
62
81
  for (const value of candidates) {
63
82
  if (typeof value === 'boolean') return value;
64
83
  }
65
84
  return undefined;
66
85
  };
67
86
 
68
- const isExpertOnlyState = (id, obj) => {
87
+ const getExpertOnlyReason = (id, obj) => {
69
88
  const explicit = getExplicitExpertOnly(obj);
70
- if (explicit !== undefined) return explicit;
89
+ if (explicit === true) return 'explicit';
90
+ if (explicit === false) return '';
71
91
 
72
- const common = getCommon(obj);
92
+ const common = obj && obj.common ? obj.common : {};
73
93
  const role = normalizeText(common.role);
74
94
  const text = normalizeText([
75
95
  id,
@@ -80,10 +100,10 @@
80
100
  ].join(' '));
81
101
  const compact = text.replace(/[^a-z0-9]+/g, '');
82
102
 
83
- // Conservative safety policy for commands that can reset equipment,
84
- // open contactors or materially change charging/power/current limits.
85
- if (/^(?:level\.)?(?:power|current)(?:\.|$)/.test(role)) return true;
86
- if (/^(?:value\.)?(?:power|current)\.setpoint(?:\.|$)/.test(role)) return true;
103
+ // High-confidence safety classes only. Normal setpoints such as room
104
+ // temperatures remain available outside expert mode.
105
+ if (/^(?:level\.)?(?:power|current)(?:\.|$)/.test(role)) return 'power-or-current-command';
106
+ if (/^(?:value\.)?(?:power|current)\.(?:setpoint|limit|target|command)(?:\.|$)/.test(role)) return 'power-or-current-command';
87
107
 
88
108
  const exactRiskTokens = [
89
109
  'hardreset', 'softreset', 'factoryreset', 'factorydefault', 'reboot', 'restartdevice', 'shutdown',
@@ -95,332 +115,237 @@
95
115
  'ladeleistung', 'entladeleistung', 'ladestrom', 'stromlimit', 'leistungsgrenze',
96
116
  'leistungssollwert', 'einspeiselimit', 'ruecksetzen', 'neustart',
97
117
  ];
98
- if (exactRiskTokens.some(token => compact.includes(token))) return true;
118
+ if (exactRiskTokens.some(token => compact.includes(token))) return 'safety-command';
99
119
 
100
120
  const powerCommand = /(?:charge|charging|discharge|export|feedin|grid|netz|pv|lade|entlade|einspeise)[\s._/-]{0,6}(?:power|current|leistung|strom).{0,28}(?:limit|setpoint|target|command|override|control|grenze|soll|vorgabe)/;
101
121
  const inversePowerCommand = /(?:limit|setpoint|target|command|override|control|grenze|soll|vorgabe).{0,28}(?:charge|charging|discharge|export|feedin|grid|netz|pv|lade|entlade|einspeise|power|current|leistung|strom)/;
102
- return powerCommand.test(text) || inversePowerCommand.test(text);
122
+ if (powerCommand.test(text) || inversePowerCommand.test(text)) return 'power-or-current-command';
123
+ return '';
103
124
  };
104
125
 
126
+ const isExpertOnlyState = (id, obj) => Boolean(getExpertOnlyReason(id, obj));
127
+
105
128
  const toBoolean = value => {
106
129
  if (value === true || value === 1) return true;
107
130
  if (value === false || value === 0 || value == null) return false;
108
- const normalized = normalizeText(value);
109
- if (['true', '1', 'on', 'yes', 'ja', 'enabled', 'enable', 'active', 'start', 'open', 'ein', 'an'].includes(normalized)) return true;
110
- if (['false', '0', 'off', 'no', 'nein', 'disabled', 'disable', 'inactive', 'stop', 'closed', 'aus', '', 'null', '(null)'].includes(normalized)) return false;
131
+ const normalized = normalizeText(value).trim();
132
+ if (['true', '1', 'on', 'yes', 'ja', 'enabled', 'active', 'open', 'start', 'ein'].includes(normalized)) return true;
133
+ if (['false', '0', 'off', 'no', 'nein', 'disabled', 'inactive', 'closed', 'stop', 'aus', '', 'null', '(null)'].includes(normalized)) return false;
111
134
  return Boolean(value);
112
135
  };
113
136
 
114
- const normalizeSingleCommonType = value => {
115
- const type = normalizeText(value);
116
- if (type === 'int' || type === 'integer' || type === 'float' || type === 'double') return 'number';
117
- if (type === 'bool') return 'boolean';
118
- if (type === 'object' || type === 'array') return type;
119
- if (type === 'json') return 'json';
120
- if (type === 'number' || type === 'boolean' || type === 'string' || type === 'mixed' || type === 'file') return type;
121
- return '';
122
- };
123
-
124
- const normalizeCommonType = value => {
125
- if (Array.isArray(value)) {
126
- const types = [...new Set(value.map(normalizeSingleCommonType).filter(Boolean))];
127
- return types.length === 1 ? types[0] : types.length ? 'mixed' : '';
137
+ const parseNumber = value => {
138
+ if (typeof value === 'number') {
139
+ if (!Number.isFinite(value)) throw new Error('Ungültiger Zahlenwert');
140
+ return value;
128
141
  }
129
- return normalizeSingleCommonType(value);
130
- };
131
-
132
- const inferPrimitiveType = value => {
133
- if (Array.isArray(value)) return 'array';
134
- if (value !== null && typeof value === 'object') return 'object';
135
- if (typeof value === 'boolean') return 'boolean';
136
- if (typeof value === 'number') return 'number';
137
- if (typeof value === 'string') return 'string';
138
- return '';
142
+ const normalized = String(value == null ? '' : value).trim().replace(',', '.');
143
+ if (!normalized) throw new Error('Zahlenwert fehlt');
144
+ const parsed = Number(normalized);
145
+ if (!Number.isFinite(parsed)) throw new Error(`Ungültiger Zahlenwert: ${value}`);
146
+ return parsed;
139
147
  };
140
148
 
141
- const normalizeStates = states => {
142
- if (!states) return null;
143
- if (Array.isArray(states)) {
144
- const result = {};
145
- states.forEach((value, index) => { result[String(index)] = flattenTranslatedText(value); });
146
- return result;
147
- }
148
- if (typeof states === 'string') {
149
- const result = {};
150
- states.split(/[;,]/).map(part => part.trim()).filter(Boolean).forEach(part => {
151
- const separator = part.includes(':') ? ':' : part.includes('=') ? '=' : '';
152
- if (!separator) return;
153
- const index = part.indexOf(separator);
154
- const key = part.slice(0, index).trim();
155
- const label = part.slice(index + 1).trim();
156
- if (key) result[key] = label || key;
157
- });
158
- return Object.keys(result).length ? result : null;
159
- }
160
- if (typeof states === 'object') {
161
- const result = {};
162
- Object.keys(states).forEach(key => { result[String(key)] = flattenTranslatedText(states[key]); });
163
- return result;
149
+ const parseJson = value => {
150
+ if (value && typeof value === 'object') return value;
151
+ const text = String(value == null ? '' : value).trim();
152
+ if (!text) throw new Error('JSON-Wert fehlt');
153
+ try {
154
+ return JSON.parse(text);
155
+ } catch (error) {
156
+ throw new Error(`Ungültiges JSON: ${error && error.message ? error.message : error}`);
164
157
  }
165
- return null;
166
158
  };
167
159
 
168
- const resolveEditorType = (obj, currentValue, states) => {
169
- const common = getCommon(obj);
170
- const normalizedStates = normalizeStates(states || common.states);
171
- if (normalizedStates && Object.keys(normalizedStates).length) return 'states';
172
-
173
- const declared = normalizeCommonType(common.type);
174
- if (declared === 'file') return 'file';
175
- if (declared === 'object' || declared === 'array' || declared === 'json') return 'json';
176
- if (declared === 'number' || declared === 'boolean' || declared === 'string') return declared;
177
-
178
- const candidate = currentValue !== undefined && currentValue !== null ? currentValue : common.def;
179
- const inferred = inferPrimitiveType(candidate);
180
- if (inferred === 'object' || inferred === 'array') return 'json';
181
- if (inferred) return inferred;
182
- return 'string';
183
- };
184
-
185
- const stringifyJsonValue = value => {
186
- if (typeof value === 'string') {
160
+ const castStateKey = (key, type) => {
161
+ if (type === 'number') return parseNumber(key);
162
+ if (type === 'boolean') return toBoolean(key);
163
+ if (type === 'mixed') {
164
+ const text = String(key).trim();
165
+ if (/^(true|false|on|off|yes|no|ja|nein|1|0)$/i.test(text)) return toBoolean(text);
166
+ if (/^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(text.replace(',', '.'))) return parseNumber(text);
187
167
  try {
188
- return JSON.stringify(JSON.parse(value), null, 2);
168
+ if (/^[\[{]/.test(text)) return JSON.parse(text);
189
169
  } catch {
190
- return value;
170
+ // keep as text
191
171
  }
172
+ return text;
192
173
  }
193
- if (value === undefined || value === null) return '';
194
- try {
195
- return JSON.stringify(value, null, 2);
196
- } catch {
197
- return String(value);
198
- }
199
- };
200
-
201
- const isNumberInRange = (value, common) => {
202
- if (value === '') return false;
203
- const number = Number(typeof value === 'string' ? value.trim().replace(',', '.') : value);
204
- if (!Number.isFinite(number)) return false;
205
- if (typeof common.min === 'number' && number < common.min) return false;
206
- if (typeof common.max === 'number' && number > common.max) return false;
207
- return true;
174
+ return String(key);
208
175
  };
209
176
 
210
- const prepareEditorValue = (obj, currentValue, states) => {
211
- const common = getCommon(obj);
212
- const type = resolveEditorType(obj, currentValue, states);
213
- const hasCurrentValue = currentValue !== undefined && currentValue !== null;
214
- const hasDefault = common.def !== undefined && common.def !== null;
215
- let value = hasCurrentValue ? currentValue : hasDefault ? common.def : undefined;
216
- let valid = true;
217
-
218
- if (type === 'number') {
219
- if (value === undefined || value === '') {
220
- value = '';
221
- valid = false;
222
- } else {
223
- const normalized = typeof value === 'string' ? value.trim().replace(',', '.') : value;
224
- const parsed = Number(normalized);
225
- if (Number.isFinite(parsed)) {
226
- value = parsed;
227
- valid = isNumberInRange(parsed, common);
228
- } else {
229
- value = String(value);
230
- valid = false;
231
- }
232
- }
233
- } else if (type === 'boolean') {
234
- value = toBoolean(value);
235
- } else if (type === 'json') {
236
- const declared = normalizeCommonType(common.type);
237
- if (value === undefined) value = declared === 'array' ? '[]' : declared === 'object' ? '{}' : '';
238
- value = stringifyJsonValue(value);
177
+ const sameValue = (left, right) => {
178
+ if (Object.is(left, right)) return true;
179
+ if (left == null || right == null) return left == null && right == null;
180
+ if (typeof left === 'object' || typeof right === 'object') {
239
181
  try {
240
- JSON.parse(value);
241
- valid = true;
182
+ return JSON.stringify(left) === JSON.stringify(right);
242
183
  } catch {
243
- valid = false;
184
+ return false;
244
185
  }
245
- } else if (type === 'states') {
246
- value = value === undefined ? '' : value;
247
- const normalizedStates = normalizeStates(states || common.states) || {};
248
- valid = value !== '' && Object.prototype.hasOwnProperty.call(normalizedStates, String(value));
249
- } else {
250
- value = value === undefined ? '' : String(value);
251
186
  }
252
-
253
- return { type, value, propsValue: value, valid };
187
+ return String(left) === String(right);
254
188
  };
255
189
 
256
- const parseNumber = (value, common) => {
257
- if (value === null || value === 'null') return null;
258
- const normalized = typeof value === 'string' ? value.trim().replace(',', '.') : value;
259
- if (normalized === '') throw new Error('Der Zahlenwert darf nicht leer sein.');
260
- const number = Number(normalized);
261
- if (!Number.isFinite(number)) throw new Error(`Ungültiger Zahlenwert: ${String(value)}`);
262
- if (typeof common.min === 'number' && number < common.min) throw new Error(`Wert liegt unter dem Minimum ${common.min}.`);
263
- if (typeof common.max === 'number' && number > common.max) throw new Error(`Wert liegt über dem Maximum ${common.max}.`);
264
- return number;
190
+ const optionScore = (option, mode) => {
191
+ const text = normalizeText(`${option.key} ${option.label}`).replace(/[^a-z0-9]+/g, ' ');
192
+ const offWords = ['false', '0', 'off', 'no', 'nein', 'disabled', 'inactive', 'closed', 'stop', 'aus'];
193
+ const onWords = ['true', '1', 'on', 'yes', 'ja', 'enabled', 'active', 'open', 'start', 'ein'];
194
+ const words = mode === 'on' ? onWords : offWords;
195
+ return words.reduce((score, word) => score + (new RegExp(`(?:^|\\s)${word}(?:\\s|$)`).test(text) ? 10 : 0), 0);
265
196
  };
266
197
 
267
- const parseJsonStateValue = (value, declared) => {
268
- const raw = value == null ? '' : typeof value === 'string' ? value : JSON.stringify(value);
269
- if (!raw.trim()) throw new Error('Der JSON-Wert darf nicht leer sein.');
270
- const parsed = JSON.parse(raw);
271
- if (declared === 'array' && !Array.isArray(parsed)) throw new Error('Der Datenpunkt erwartet ein JSON-Array.');
272
- if (declared === 'object' && (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))) {
273
- throw new Error('Der Datenpunkt erwartet ein JSON-Objekt.');
198
+ const getBinaryOptions = obj => {
199
+ const common = obj && obj.common ? obj.common : {};
200
+ const type = normalizeCommonType(obj);
201
+ const entries = normalizeStates(common.states);
202
+ if (entries.length > 2) return null;
203
+ if (entries.length === 2) {
204
+ const typed = entries.map(entry => ({ ...entry, value: castStateKey(entry.key, type) }));
205
+ const firstOff = optionScore(typed[0], 'off');
206
+ const firstOn = optionScore(typed[0], 'on');
207
+ const secondOff = optionScore(typed[1], 'off');
208
+ const secondOn = optionScore(typed[1], 'on');
209
+ if (firstOn > firstOff && secondOff >= secondOn) return { off: typed[1].value, on: typed[0].value };
210
+ if (secondOn > secondOff && firstOff >= firstOn) return { off: typed[0].value, on: typed[1].value };
211
+ return { off: typed[0].value, on: typed[1].value };
212
+ }
213
+ if (type === 'boolean') return { off: false, on: true };
214
+ if (type === 'number') {
215
+ const off = typeof common.min === 'number' ? common.min : 0;
216
+ let on = typeof common.max === 'number' ? common.max : 1;
217
+ if (sameValue(off, on)) on = off === 0 ? 1 : 0;
218
+ return { off, on };
274
219
  }
275
- // ioBroker StateValue is scalar. Structured values are therefore stored
276
- // as a validated JSON string instead of passing an unsupported object.
277
- return raw;
220
+ if (type === 'string') return { off: 'false', on: 'true' };
221
+ if (type === 'mixed') return { off: false, on: true };
222
+ return null;
278
223
  };
279
224
 
280
- const coerceByDeclaredType = (obj, value, currentValue) => {
281
- const common = getCommon(obj);
282
- const declared = normalizeCommonType(common.type);
283
- if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'value')) value = value.value;
284
-
285
- if (declared === 'number') return parseNumber(value, common);
286
- if (declared === 'boolean') return value === null || value === 'null' ? null : toBoolean(value);
287
- if (declared === 'string') return value === null ? '' : String(value);
288
- if (declared === 'object' || declared === 'array' || declared === 'json') return parseJsonStateValue(value, declared);
289
-
290
- // mixed or missing type: preserve the current/default primitive type where possible.
291
- const candidate = currentValue !== undefined && currentValue !== null ? currentValue : common.def;
292
- const inferred = inferPrimitiveType(candidate);
293
- if (inferred === 'number') return parseNumber(value, common);
294
- if (inferred === 'boolean') return value === null || value === 'null' ? null : toBoolean(value);
295
- if (inferred === 'object' || inferred === 'array') return parseJsonStateValue(value, inferred);
296
- if (typeof value === 'string' && value.trim() === 'null') return null;
297
- return value == null ? '' : value;
225
+ const isButtonState = (obj, item) => {
226
+ const common = obj && obj.common ? obj.common : {};
227
+ const role = normalizeText(common.role);
228
+ const data = item && item.data ? item.data : {};
229
+ return Boolean(data.button) || role === 'button' || role.startsWith('button.') || role === 'action' || role.startsWith('action.');
298
230
  };
299
231
 
300
- const coerceStateKey = (obj, value, currentValue) => {
301
- const raw = value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'value') ? value.value : value;
302
- return coerceByDeclaredType(obj, raw, currentValue);
232
+ const isSwitchState = (obj, item) => {
233
+ const common = obj && obj.common ? obj.common : {};
234
+ const role = normalizeText(common.role);
235
+ const data = item && item.data ? item.data : {};
236
+ const requested = Boolean(data.switch) || role === 'switch' || role.startsWith('switch.') || normalizeCommonType(obj) === 'boolean';
237
+ return requested && Boolean(getBinaryOptions(obj));
303
238
  };
304
239
 
305
- const coerceWriteValue = (obj, rawValue, editorType, originalType, currentValue) => {
306
- const common = getCommon(obj);
307
- if (common.write === false) throw new Error('common.write=false');
308
-
309
- if (editorType === 'states') return coerceStateKey(obj, rawValue, currentValue);
310
- if (editorType === 'number') return parseNumber(rawValue, common);
311
- if (editorType === 'boolean') return rawValue === null || rawValue === 'null' ? null : toBoolean(rawValue);
312
- if (editorType === 'json') return parseJsonStateValue(rawValue, normalizeCommonType(common.type));
313
- if (editorType === 'string') return rawValue == null ? '' : String(rawValue);
314
- return coerceByDeclaredType(obj, rawValue, currentValue);
240
+ const getWriteBehavior = (id, obj, item, expertMode) => {
241
+ if (!obj || obj.type !== 'state') return 'readonly';
242
+ const common = obj.common || {};
243
+ if (common.write === false) return 'readonly';
244
+ if (isExpertOnlyState(id, obj) && !expertMode) return 'expert-only';
245
+ if (normalizeCommonType(obj) === 'file') return 'file';
246
+ if (isButtonState(obj, item) && (common.def !== undefined || !['array', 'object'].includes(normalizeCommonType(obj)))) return 'button';
247
+ if (isSwitchState(obj, item)) return 'switch';
248
+ return 'dialog';
315
249
  };
316
250
 
317
- const booleanTokens = {
318
- true: ['true', '1', 'on', 'yes', 'ja', 'enabled', 'enable', 'active', 'start', 'open', 'ein', 'an'],
319
- false: ['false', '0', 'off', 'no', 'nein', 'disabled', 'disable', 'inactive', 'stop', 'closed', 'aus'],
251
+ const getDirectWriteValue = (id, obj, item, currentValue, behavior) => {
252
+ const common = obj && obj.common ? obj.common : {};
253
+ const type = normalizeCommonType(obj, currentValue);
254
+ const mode = behavior || getWriteBehavior(id, obj, item, true);
255
+ if (mode === 'button') {
256
+ if (common.def !== undefined) return common.def;
257
+ const options = getBinaryOptions(obj);
258
+ if (options) return options.on;
259
+ if (type === 'boolean') return true;
260
+ if (type === 'number') return 1;
261
+ if (type === 'string') return 'true';
262
+ if (type === 'mixed') return true;
263
+ throw new Error(`Button-Datenpunkt ${id} benötigt einen expliziten Vorgabewert`);
264
+ }
265
+ if (mode === 'switch') {
266
+ const options = getBinaryOptions(obj);
267
+ if (!options) throw new Error(`Datenpunkt ${id} ist kein binärer Schalter`);
268
+ return sameValue(currentValue, options.on) ? options.off : options.on;
269
+ }
270
+ throw new Error(`Direktes Schreiben ist für ${mode} nicht vorgesehen`);
320
271
  };
321
272
 
322
- const stringTogglePairs = [
323
- ['off', 'on'], ['false', 'true'], ['0', '1'], ['disabled', 'enabled'], ['disable', 'enable'],
324
- ['inactive', 'active'], ['stop', 'start'], ['closed', 'open'], ['aus', 'ein'], ['nein', 'ja'],
325
- ];
326
-
327
- const findBooleanStateKey = (states, desired) => {
328
- const normalized = normalizeStates(states);
329
- if (!normalized) return undefined;
330
- const tokens = desired ? booleanTokens.true : booleanTokens.false;
331
- for (const [key, label] of Object.entries(normalized)) {
332
- if (tokens.includes(normalizeText(key)) || tokens.includes(normalizeText(label))) return key;
333
- }
334
- return undefined;
273
+ const inferEditorType = (obj, currentValue) => {
274
+ const common = obj && obj.common ? obj.common : {};
275
+ if (normalizeStates(common.states).length) return 'states';
276
+ const type = normalizeCommonType(obj, currentValue);
277
+ if (type === 'array' || type === 'object' || type === 'json') return 'json';
278
+ if (type === 'mixed') return 'json';
279
+ if (type === 'number' || type === 'boolean' || type === 'string') return type;
280
+ return 'string';
335
281
  };
336
282
 
337
- const getStringToggleValue = (currentValue, desired) => {
338
- const raw = String(currentValue == null ? '' : currentValue).trim();
339
- const current = normalizeText(raw);
340
- let result = desired ? 'true' : 'false';
341
- for (const [falseToken, trueToken] of stringTogglePairs) {
342
- if (current === falseToken || current === trueToken) {
343
- result = desired ? trueToken : falseToken;
344
- break;
283
+ const prepareEditor = (obj, currentValue) => {
284
+ const editorType = inferEditorType(obj, currentValue);
285
+ const originalType = normalizeCommonType(obj, currentValue);
286
+ let value = currentValue;
287
+ if (editorType === 'json') {
288
+ if (typeof value === 'string') {
289
+ try {
290
+ value = JSON.stringify(JSON.parse(value), null, 2);
291
+ } catch {
292
+ value = originalType === 'mixed' ? JSON.stringify(value) : value;
293
+ }
294
+ } else {
295
+ value = JSON.stringify(value == null ? null : value, null, 2);
345
296
  }
297
+ } else if (editorType === 'number') {
298
+ value = value == null ? '' : value;
299
+ } else if (editorType === 'boolean') {
300
+ value = toBoolean(value);
301
+ } else if (editorType === 'string') {
302
+ value = value == null ? '' : String(value);
346
303
  }
347
- if (raw && raw === raw.toUpperCase()) return result.toUpperCase();
348
- if (raw && raw[0] === raw[0].toUpperCase() && raw.slice(1) === raw.slice(1).toLowerCase()) {
349
- return result.charAt(0).toUpperCase() + result.slice(1);
350
- }
351
- return result;
304
+ return {
305
+ editorType,
306
+ value,
307
+ originalType,
308
+ };
352
309
  };
353
310
 
354
- const getDirectWriteValue = (id, obj, item, behavior, currentValue) => {
355
- const common = getCommon(obj);
356
- const desired = behavior === 'button' ? true : !toBoolean(currentValue);
357
- const explicitName = behavior === 'button' ? 'manualTriggerValue' : desired ? 'manualTrueValue' : 'manualFalseValue';
358
- const explicit = getExplicitValue(obj, explicitName);
359
- if (explicit !== undefined) return coerceByDeclaredType(obj, explicit, currentValue);
311
+ const parseEditorValue = (obj, editorType, rawValue) => {
312
+ const common = obj && obj.common ? obj.common : {};
313
+ const originalType = normalizeCommonType(obj, rawValue);
314
+ let value = rawValue && typeof rawValue === 'object' && Object.prototype.hasOwnProperty.call(rawValue, 'value')
315
+ ? rawValue.value
316
+ : rawValue;
360
317
 
361
- const stateKey = findBooleanStateKey(common.states, desired);
362
- if (stateKey !== undefined) return coerceStateKey(obj, stateKey, currentValue);
318
+ if (editorType !== 'string' && value === 'null') return null;
363
319
 
364
- if (behavior === 'button' && common.def !== undefined && common.def !== null) {
365
- return coerceByDeclaredType(obj, common.def, currentValue);
320
+ if (editorType === 'states') {
321
+ value = castStateKey(value, originalType);
322
+ } else if (editorType === 'number') {
323
+ value = parseNumber(value);
324
+ } else if (editorType === 'boolean') {
325
+ value = toBoolean(value);
326
+ } else if (editorType === 'json') {
327
+ value = parseJson(value);
328
+ } else {
329
+ value = value == null ? '' : String(value);
366
330
  }
367
331
 
368
- const declared = normalizeCommonType(common.type);
369
- if (declared === 'number') {
370
- if (behavior === 'switch') {
371
- if (desired && typeof common.max === 'number') return common.max;
372
- if (!desired && typeof common.min === 'number') return common.min;
373
- }
374
- return desired ? 1 : 0;
375
- }
376
- if (declared === 'string') return getStringToggleValue(currentValue, desired);
377
- if (declared === 'object' || declared === 'array' || declared === 'json') {
378
- throw new Error('Objekt-/Array-Befehle müssen über den Wertdialog gesetzt werden.');
332
+ if (originalType === 'number' && typeof value !== 'number') value = parseNumber(value);
333
+ if (originalType === 'boolean' && typeof value !== 'boolean') value = toBoolean(value);
334
+ if (originalType === 'string' && typeof value !== 'string') value = String(value);
335
+ if (originalType === 'array') {
336
+ if (typeof value === 'string') value = parseJson(value);
337
+ if (!Array.isArray(value)) throw new Error('Der Datenpunkt erwartet ein JSON-Array');
379
338
  }
380
- if (declared === 'mixed') {
381
- if (typeof currentValue === 'number') return desired ? 1 : 0;
382
- if (typeof currentValue === 'string') return getStringToggleValue(currentValue, desired);
339
+ if (originalType === 'object') {
340
+ if (typeof value === 'string') value = parseJson(value);
341
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Der Datenpunkt erwartet ein JSON-Objekt');
383
342
  }
384
- return desired;
385
- };
386
343
 
387
- const getWriteBehavior = (id, obj, item, expertMode) => {
388
- if (!obj || obj.type !== 'state') return 'readonly';
389
- const common = getCommon(obj);
390
- if (common.write === false) return 'readonly';
391
- if (isExpertOnlyState(id, obj) && !expertMode) return 'expert-only';
392
- if (normalizeCommonType(common.type) === 'file') return 'file';
393
-
394
- const role = normalizeText(common.role);
395
- const data = item && item.data ? item.data : {};
396
- const declared = normalizeCommonType(common.type);
397
- const explicitControl = normalizeText(
398
- getExplicitValue(obj, 'manualWriteControl') !== undefined
399
- ? getExplicitValue(obj, 'manualWriteControl')
400
- : getExplicitValue(obj, 'manualControl'),
401
- );
402
- if (['button', 'trigger', 'action'].includes(explicitControl) && !['object', 'array', 'json'].includes(declared)) return 'button';
403
- if (['dialog', 'value', 'editor'].includes(explicitControl)) return 'dialog';
404
- if (['switch', 'toggle'].includes(explicitControl)) return 'switch';
405
-
406
- const normalizedStates = normalizeStates(common.states);
407
- const stateCount = normalizedStates ? Object.keys(normalizedStates).length : 0;
408
- const isButton = Boolean(data.button) || role === 'button' || role.startsWith('button.') || role === 'action' || role.startsWith('action.') || role === 'trigger' || role.startsWith('trigger.');
409
- if (isButton && declared !== 'object' && declared !== 'array' && declared !== 'json') return 'button';
410
- if (declared === 'boolean') return 'switch';
411
- // A numeric/string mode with more than two states is a selector, not a
412
- // toggle. Treating it as a switch would make only two of its values reachable.
413
- if ((Boolean(data.switch) || role === 'switch' || role.startsWith('switch.')) && stateCount <= 2) return 'switch';
414
- return 'dialog';
415
- };
416
-
417
- const getWriteLabel = (id, obj, behavior, expertMode, hasState) => {
418
- if (behavior === 'readonly') return 'Nur lesbar';
419
- if (behavior === 'expert-only') return 'Nur im Expertenmodus beschreibbar';
420
- if (behavior === 'button') return 'Befehl auslösen';
421
- if (behavior === 'switch') return 'Schalten';
422
- if (!hasState) return 'Wert setzen (schreibender Datenpunkt ohne aktuellen Lesewert)';
423
- return 'Wert bearbeiten';
344
+ if (typeof value === 'number') {
345
+ if (typeof common.min === 'number' && value < common.min) throw new Error(`Wert liegt unter dem Minimum ${common.min}`);
346
+ if (typeof common.max === 'number' && value > common.max) throw new Error(`Wert liegt über dem Maximum ${common.max}`);
347
+ }
348
+ return value;
424
349
  };
425
350
 
426
351
  const writeManualState = (socket, id, value) => {
@@ -429,41 +354,41 @@
429
354
  }
430
355
  const key = String(id || '');
431
356
  if (!key) return Promise.reject(new Error('Missing datapoint ID'));
432
- if (value === undefined) return Promise.reject(new Error(`Undefined is not a valid state value for ${key}`));
433
- if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) {
434
- return Promise.reject(new Error(`Unsupported state value type for ${key}: ${typeof value}`));
435
- }
436
357
 
437
- const previous = writeQueues.get(key) || Promise.resolve();
438
- const operation = previous.catch(() => undefined).then(() => {
439
- let timeoutHandle;
440
- const write = Promise.resolve().then(() => socket.setState(key, { val: value, ack: false, q: 0 }));
358
+ const previous = pendingWrites.get(key);
359
+ if (previous && sameValue(previous.value, value)) return previous.promise;
360
+
361
+ const predecessor = previous ? previous.promise.catch(() => undefined) : Promise.resolve();
362
+ let timeoutHandle;
363
+ const operation = predecessor.then(() => {
364
+ const write = Promise.resolve(socket.setState(key, { val: value, ack: false, q: 0 }));
441
365
  const timeout = new Promise((_, reject) => {
442
366
  timeoutHandle = setTimeout(() => reject(new Error(`Write timeout for ${key}`)), 15000);
443
367
  });
444
- return Promise.race([write, timeout]).finally(() => {
445
- if (timeoutHandle) clearTimeout(timeoutHandle);
446
- });
368
+ return Promise.race([write, timeout]);
447
369
  });
448
- const tail = operation.finally(() => {
449
- if (writeQueues.get(key) === tail) writeQueues.delete(key);
370
+ const promise = operation.finally(() => {
371
+ if (timeoutHandle) clearTimeout(timeoutHandle);
372
+ const active = pendingWrites.get(key);
373
+ if (active && active.promise === promise) pendingWrites.delete(key);
450
374
  });
451
- writeQueues.set(key, tail);
452
- return operation;
375
+ pendingWrites.set(key, { value, promise });
376
+ return promise;
453
377
  };
454
378
 
455
379
  return {
380
+ normalizeCommonType,
381
+ normalizeStates,
456
382
  getExplicitExpertOnly,
383
+ getExpertOnlyReason,
457
384
  isExpertOnlyState,
385
+ getBinaryOptions,
458
386
  getWriteBehavior,
459
- getWriteLabel,
460
- resolveEditorType,
461
- prepareEditorValue,
462
- coerceWriteValue,
463
387
  getDirectWriteValue,
388
+ inferEditorType,
389
+ prepareEditor,
390
+ parseEditorValue,
464
391
  toBoolean,
465
392
  writeManualState,
466
- normalizeStates,
467
- normalizeCommonType,
468
393
  };
469
394
  });