iobroker.loxone 3.0.0 → 4.0.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/LICENSE +1 -1
- package/README.md +425 -402
- package/admin/i18n/de.json +15 -0
- package/admin/i18n/en.json +15 -0
- package/admin/i18n/es.json +18 -0
- package/admin/i18n/fr.json +18 -0
- package/admin/i18n/it.json +18 -0
- package/admin/i18n/nl.json +18 -0
- package/admin/i18n/pl.json +18 -0
- package/admin/i18n/pt.json +18 -0
- package/admin/i18n/ru.json +18 -0
- package/admin/i18n/uk.json +18 -0
- package/admin/i18n/zh-cn.json +18 -0
- package/admin/jsonConfig.json +96 -0
- package/io-package.json +309 -349
- package/package.json +42 -46
- package/src/controls/AalEmergency.ts +90 -0
- package/src/controls/AalSmartAlarm.ts +99 -0
- package/src/controls/Alarm.ts +146 -0
- package/src/controls/AlarmClock.ts +137 -0
- package/src/controls/Application.ts +13 -0
- package/src/controls/AudioZone.ts +227 -0
- package/src/controls/AudioZoneV2.ts +135 -0
- package/src/controls/CentralAlarm.ts +59 -0
- package/src/controls/CentralAudioZone.ts +41 -0
- package/src/controls/CentralGate.ts +53 -0
- package/src/controls/CentralJalousie.ts +67 -0
- package/src/controls/CentralLightController.ts +45 -0
- package/src/controls/ColorPickerV2.ts +34 -0
- package/src/controls/Colorpicker.ts +30 -0
- package/src/controls/ColorpickerBase.ts +326 -0
- package/src/controls/Daytimer.ts +201 -0
- package/src/controls/Dimmer.ts +64 -0
- package/src/controls/EIBDimmer.ts +61 -0
- package/src/controls/Fronius.ts +217 -0
- package/src/controls/Gate.ts +150 -0
- package/src/controls/Hourcounter.ts +115 -0
- package/src/controls/IRCDaytimer.ts +4 -0
- package/src/controls/IRCV2Daytimer.ts +4 -0
- package/src/controls/IRoomControllerV2.ts +595 -0
- package/src/controls/InfoOnlyAnalog.ts +56 -0
- package/src/controls/InfoOnlyDigital.ts +95 -0
- package/src/controls/InfoOnlyText.ts +52 -0
- package/{build/controls/Intercom.js → src/controls/Intercom.ts} +27 -11
- package/src/controls/Jalousie.ts +219 -0
- package/src/controls/LightController.ts +112 -0
- package/src/controls/LightControllerV2.ts +246 -0
- package/src/controls/MailBox.ts +92 -0
- package/src/controls/Meter.ts +94 -0
- package/src/controls/None.ts +29 -0
- package/src/controls/PresenceDetector.ts +47 -0
- package/src/controls/Pushbutton.ts +55 -0
- package/src/controls/Radio.ts +61 -0
- package/src/controls/Remote.ts +44 -0
- package/src/controls/Slider.ts +78 -0
- package/src/controls/SmokeAlarm.ts +163 -0
- package/src/controls/Switch.ts +46 -0
- package/src/controls/SystemScheme.ts +13 -0
- package/src/controls/TextInput.ts +96 -0
- package/src/controls/TextState.ts +37 -0
- package/src/controls/TimedSwitch.ts +75 -0
- package/src/controls/Tracker.ts +30 -0
- package/{build/controls/Unknown.js → src/controls/Unknown.ts} +19 -13
- package/src/controls/UpDownAnalog.ts +78 -0
- package/{build/controls/ValueSelector.js → src/controls/ValueSelector.ts} +21 -9
- package/src/controls/WindowMonitor.ts +139 -0
- package/src/controls/all-controls.ts +99 -0
- package/src/controls/control-base.ts +28 -0
- package/src/lib/adapter-config.d.ts +22 -0
- package/src/loxone-handler-base.ts +285 -0
- package/src/lxcommunicator.d.ts +1 -0
- package/src/main.test.ts +24 -0
- package/src/main.ts +1178 -0
- package/src/structure-file.d.ts +154 -0
- package/src/weather-server-handler.ts +266 -0
- package/admin/admin.d.ts +0 -58
- package/admin/index_m.html +0 -126
- package/admin/style.css +0 -32
- package/admin/words.js +0 -25
- package/build/controls/AalEmergency.js +0 -45
- package/build/controls/AalEmergency.js.map +0 -1
- package/build/controls/AalSmartAlarm.js +0 -48
- package/build/controls/AalSmartAlarm.js.map +0 -1
- package/build/controls/Alarm.js +0 -81
- package/build/controls/Alarm.js.map +0 -1
- package/build/controls/AlarmClock.js +0 -59
- package/build/controls/AlarmClock.js.map +0 -1
- package/build/controls/Application.js +0 -11
- package/build/controls/Application.js.map +0 -1
- package/build/controls/AudioZone.js +0 -116
- package/build/controls/AudioZone.js.map +0 -1
- package/build/controls/CentralAlarm.js +0 -30
- package/build/controls/CentralAlarm.js.map +0 -1
- package/build/controls/CentralAudioZone.js +0 -22
- package/build/controls/CentralAudioZone.js.map +0 -1
- package/build/controls/CentralGate.js +0 -30
- package/build/controls/CentralGate.js.map +0 -1
- package/build/controls/CentralJalousie.js +0 -39
- package/build/controls/CentralJalousie.js.map +0 -1
- package/build/controls/CentralLightController.js +0 -27
- package/build/controls/CentralLightController.js.map +0 -1
- package/build/controls/ColorPickerV2.js +0 -24
- package/build/controls/ColorPickerV2.js.map +0 -1
- package/build/controls/Colorpicker.js +0 -20
- package/build/controls/Colorpicker.js.map +0 -1
- package/build/controls/ColorpickerBase.js +0 -263
- package/build/controls/ColorpickerBase.js.map +0 -1
- package/build/controls/Daytimer.js +0 -119
- package/build/controls/Daytimer.js.map +0 -1
- package/build/controls/Dimmer.js +0 -34
- package/build/controls/Dimmer.js.map +0 -1
- package/build/controls/EIBDimmer.js +0 -31
- package/build/controls/EIBDimmer.js.map +0 -1
- package/build/controls/Fronius.js +0 -64
- package/build/controls/Fronius.js.map +0 -1
- package/build/controls/Gate.js +0 -109
- package/build/controls/Gate.js.map +0 -1
- package/build/controls/Hourcounter.js +0 -46
- package/build/controls/Hourcounter.js.map +0 -1
- package/build/controls/IRCDaytimer.js +0 -9
- package/build/controls/IRCDaytimer.js.map +0 -1
- package/build/controls/IRoomControllerV2.js +0 -272
- package/build/controls/IRoomControllerV2.js.map +0 -1
- package/build/controls/InfoOnlyAnalog.js +0 -38
- package/build/controls/InfoOnlyAnalog.js.map +0 -1
- package/build/controls/InfoOnlyDigital.js +0 -65
- package/build/controls/InfoOnlyDigital.js.map +0 -1
- package/build/controls/InfoOnlyText.js +0 -35
- package/build/controls/InfoOnlyText.js.map +0 -1
- package/build/controls/Intercom.js.map +0 -1
- package/build/controls/Jalousie.js +0 -156
- package/build/controls/Jalousie.js.map +0 -1
- package/build/controls/LightController.js +0 -68
- package/build/controls/LightController.js.map +0 -1
- package/build/controls/LightControllerV2.js +0 -195
- package/build/controls/LightControllerV2.js.map +0 -1
- package/build/controls/MailBox.js +0 -41
- package/build/controls/MailBox.js.map +0 -1
- package/build/controls/Meter.js +0 -52
- package/build/controls/Meter.js.map +0 -1
- package/build/controls/None.js +0 -23
- package/build/controls/None.js.map +0 -1
- package/build/controls/PresenceDetector.js +0 -29
- package/build/controls/PresenceDetector.js.map +0 -1
- package/build/controls/Pushbutton.js +0 -38
- package/build/controls/Pushbutton.js.map +0 -1
- package/build/controls/Radio.js +0 -42
- package/build/controls/Radio.js.map +0 -1
- package/build/controls/Slider.js +0 -44
- package/build/controls/Slider.js.map +0 -1
- package/build/controls/SmokeAlarm.js +0 -85
- package/build/controls/SmokeAlarm.js.map +0 -1
- package/build/controls/Switch.js +0 -34
- package/build/controls/Switch.js.map +0 -1
- package/build/controls/SystemScheme.js +0 -11
- package/build/controls/SystemScheme.js.map +0 -1
- package/build/controls/TextInput.js +0 -55
- package/build/controls/TextInput.js.map +0 -1
- package/build/controls/TextState.js +0 -20
- package/build/controls/TextState.js.map +0 -1
- package/build/controls/TimedSwitch.js +0 -36
- package/build/controls/TimedSwitch.js.map +0 -1
- package/build/controls/Tracker.js +0 -20
- package/build/controls/Tracker.js.map +0 -1
- package/build/controls/Unknown.js.map +0 -1
- package/build/controls/UpDownAnalog.js +0 -44
- package/build/controls/UpDownAnalog.js.map +0 -1
- package/build/controls/ValueSelector.js.map +0 -1
- package/build/controls/WindowMonitor.js +0 -84
- package/build/controls/WindowMonitor.js.map +0 -1
- package/build/controls/control-base.js +0 -12
- package/build/controls/control-base.js.map +0 -1
- package/build/loxone-handler-base.js +0 -186
- package/build/loxone-handler-base.js.map +0 -1
- package/build/main.js +0 -643
- package/build/main.js.map +0 -1
- package/build/weather-server-handler.js +0 -120
- package/build/weather-server-handler.js.map +0 -1
- package/doc/details-missing-control-type.png +0 -0
- package/doc/log-missing-control-type.png +0 -0
- package/doc/loxone-config-display-diagnostics.png +0 -0
- package/doc/loxone-config-info-only-digital.png +0 -0
- package/doc/loxone-config-use-in-visualization.png +0 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
|
|
2
|
+
import * as utils from '@iobroker/adapter-core';
|
|
3
|
+
import type * as SentryNode from '@sentry/node';
|
|
4
|
+
import type { EventProcessor } from '@sentry/types';
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import FormData from 'form-data';
|
|
7
|
+
import * as LxCommunicator from 'lxcommunicator';
|
|
8
|
+
import Queue from 'queue-fifo';
|
|
9
|
+
import { v4 } from 'uuid';
|
|
10
|
+
import { Unknown } from './controls/Unknown';
|
|
11
|
+
import type { ControlBase, ControlType } from './controls/control-base';
|
|
12
|
+
import type {
|
|
13
|
+
Control,
|
|
14
|
+
Controls,
|
|
15
|
+
GlobalStates,
|
|
16
|
+
OperatingModes,
|
|
17
|
+
StructureFile,
|
|
18
|
+
WeatherServer,
|
|
19
|
+
} from './structure-file.ts';
|
|
20
|
+
import { WeatherServerHandler } from './weather-server-handler';
|
|
21
|
+
import { AllControls } from './controls/all-controls';
|
|
22
|
+
|
|
23
|
+
const WebSocketConfig = LxCommunicator.WebSocketConfig;
|
|
24
|
+
|
|
25
|
+
export type OldStateValue = ioBroker.StateValue | null | undefined;
|
|
26
|
+
export type CurrentStateValue = ioBroker.StateValue | null;
|
|
27
|
+
|
|
28
|
+
export type StateChangeListener = (oldValue: OldStateValue, newValue: CurrentStateValue) => void;
|
|
29
|
+
export type StateChangeListenerOpts = {
|
|
30
|
+
/** Don't call if new/old vals are the same & automatically ack state change */
|
|
31
|
+
notIfEqual?: boolean;
|
|
32
|
+
/** Convert state values to integers */
|
|
33
|
+
convertToInt?: boolean;
|
|
34
|
+
/** Values below minInt will be set to this value */
|
|
35
|
+
minInt?: number;
|
|
36
|
+
/** Values above maxInt will be set to this value */
|
|
37
|
+
maxInt?: number;
|
|
38
|
+
/** Override default timeout */
|
|
39
|
+
ackTimeoutMs?: number;
|
|
40
|
+
/** Acknowledge ourself (don't wait for Loxone) */
|
|
41
|
+
selfAck?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type StateChangeListenEntry = {
|
|
45
|
+
/** The listener function to call on state change */
|
|
46
|
+
listener: StateChangeListener;
|
|
47
|
+
/** Options for this listener */
|
|
48
|
+
opts?: StateChangeListenerOpts;
|
|
49
|
+
/** Queued value if a state change is in progress */
|
|
50
|
+
queuedVal: ioBroker.StateValue | null;
|
|
51
|
+
/** Timer for ack timeout */
|
|
52
|
+
ackTimer: ioBroker.Timeout | undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Log warnings if no ack event from Loxone in this time
|
|
56
|
+
// TODO: should this be configurable?
|
|
57
|
+
const ackTimeoutMs = 500;
|
|
58
|
+
|
|
59
|
+
// Period between connection attempts
|
|
60
|
+
const reconnectTimeoutMs = 5000;
|
|
61
|
+
|
|
62
|
+
export type StateEventHandler = (value: any) => Promise<void> | void;
|
|
63
|
+
export type StateEventRegistration = {
|
|
64
|
+
/** The name of the event handler used for removal */
|
|
65
|
+
name?: string;
|
|
66
|
+
/** The event handler function */
|
|
67
|
+
handler: StateEventHandler;
|
|
68
|
+
};
|
|
69
|
+
export type NamedStateEventHandler = (id: string, value: any) => Promise<void>;
|
|
70
|
+
export type LoxoneEvent = {
|
|
71
|
+
/** The UUID of the event */
|
|
72
|
+
uuid: string;
|
|
73
|
+
/** The event data */
|
|
74
|
+
evt: any;
|
|
75
|
+
};
|
|
76
|
+
export type Sentry = typeof SentryNode;
|
|
77
|
+
|
|
78
|
+
export type FormatInfoDetailsCallback = ((src: InfoDetailsEntryMap) => ioBroker.StateValue) | null;
|
|
79
|
+
export type InfoDetailsEntry = {
|
|
80
|
+
/** The event counter. */
|
|
81
|
+
count: number;
|
|
82
|
+
/** The last value received for this event. */
|
|
83
|
+
lastValue?: any;
|
|
84
|
+
};
|
|
85
|
+
export type InfoDetailsEntryMap = Map<string, InfoDetailsEntry>;
|
|
86
|
+
export type InfoEntry = {
|
|
87
|
+
/** The current state value */
|
|
88
|
+
value: ioBroker.StateValue;
|
|
89
|
+
/** The last set state value */
|
|
90
|
+
lastSet: ioBroker.StateValue;
|
|
91
|
+
/** Timer for state value reset */
|
|
92
|
+
timer: ioBroker.Timeout | undefined;
|
|
93
|
+
/** Map of detailed information entries */
|
|
94
|
+
detailsMap?: InfoDetailsEntryMap;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The Loxone adapter main class.
|
|
99
|
+
*/
|
|
100
|
+
export class Loxone extends utils.Adapter {
|
|
101
|
+
private uuid = '';
|
|
102
|
+
private socket?: any;
|
|
103
|
+
private existingObjects: Record<string, ioBroker.Object> = {};
|
|
104
|
+
private currentStateValues: Record<string, CurrentStateValue> = {};
|
|
105
|
+
private operatingModes: OperatingModes = {};
|
|
106
|
+
private foundRooms: Record<string, string[]> = {};
|
|
107
|
+
private foundCats: Record<string, string[]> = {};
|
|
108
|
+
|
|
109
|
+
private stateChangeListeners: Record<string, StateChangeListenEntry> = {};
|
|
110
|
+
private stateEventHandlers: Record<string, StateEventRegistration[]> = {};
|
|
111
|
+
|
|
112
|
+
private readonly eventsQueue = new Queue<LoxoneEvent>();
|
|
113
|
+
private runQueue = false;
|
|
114
|
+
private queueRunning = false;
|
|
115
|
+
|
|
116
|
+
public readonly reportedMissingControls = new Set<string>();
|
|
117
|
+
private readonly reportedUnsupportedStateChanges = new Set<string>();
|
|
118
|
+
private reconnectTimer?: ioBroker.Timeout;
|
|
119
|
+
private connectionInProgress = false;
|
|
120
|
+
private lxConnected = false;
|
|
121
|
+
|
|
122
|
+
private info: Map<string, InfoEntry>;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Creates an instance of the Loxone adapter.
|
|
126
|
+
*
|
|
127
|
+
* @param options The adapter options.
|
|
128
|
+
*/
|
|
129
|
+
public constructor(options: Partial<utils.AdapterOptions> = {}) {
|
|
130
|
+
super({
|
|
131
|
+
dirname: __dirname.indexOf('node_modules') !== -1 ? undefined : `${__dirname}/../`,
|
|
132
|
+
...options,
|
|
133
|
+
name: 'loxone',
|
|
134
|
+
});
|
|
135
|
+
this.on('ready', this.onReady.bind(this));
|
|
136
|
+
this.on('stateChange', this.onStateChange.bind(this));
|
|
137
|
+
this.on('unload', this.onUnload.bind(this));
|
|
138
|
+
this.info = new Map<string, InfoEntry>();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Is called when databases are connected and adapter received configuration.
|
|
143
|
+
*/
|
|
144
|
+
private async onReady(): Promise<void> {
|
|
145
|
+
// Init info
|
|
146
|
+
await this.initInfoStates();
|
|
147
|
+
|
|
148
|
+
// store all current (acknowledged) state values
|
|
149
|
+
const allStates = await this.getStatesAsync('*');
|
|
150
|
+
for (const id in allStates) {
|
|
151
|
+
if (allStates[id] && allStates[id].ack) {
|
|
152
|
+
this.currentStateValues[id] = allStates[id].val;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// store all existing objects for later use
|
|
157
|
+
this.existingObjects = await this.getAdapterObjectsAsync();
|
|
158
|
+
|
|
159
|
+
// Reset the connection indicator during startup
|
|
160
|
+
this.setConnectionState(false);
|
|
161
|
+
this.uuid = v4();
|
|
162
|
+
// connect to Loxone Miniserver
|
|
163
|
+
const webSocketConfig = new WebSocketConfig(
|
|
164
|
+
WebSocketConfig.protocol.WS,
|
|
165
|
+
this.uuid,
|
|
166
|
+
'iobroker',
|
|
167
|
+
WebSocketConfig.permission.APP,
|
|
168
|
+
false,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const handleAnyEvent = (uuid: string, evt: any): void => {
|
|
172
|
+
this.log.silly(`received update event: ${JSON.stringify(evt)}: ${uuid}`);
|
|
173
|
+
this.eventsQueue.enqueue({ uuid, evt });
|
|
174
|
+
this.handleEventQueue().catch(e => {
|
|
175
|
+
this.log.error(`Unhandled error in event ${uuid}: ${e}`);
|
|
176
|
+
this.getSentry()?.captureException(e, { extra: { uuid, evt } });
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
webSocketConfig.delegate = {
|
|
181
|
+
socketOnDataProgress: (socket: any, progress: any) => {
|
|
182
|
+
this.log.debug(`data progress ${progress}`);
|
|
183
|
+
},
|
|
184
|
+
socketOnTokenConfirmed: (_socket: any, _response: any) => {
|
|
185
|
+
this.log.debug('token confirmed');
|
|
186
|
+
},
|
|
187
|
+
socketOnTokenReceived: (_socket: any, _result: any) => {
|
|
188
|
+
this.log.debug('token received');
|
|
189
|
+
},
|
|
190
|
+
socketOnConnectionClosed: (socket: any, code: string) => {
|
|
191
|
+
this.log.info(`Socket closed ${code}`);
|
|
192
|
+
this.setConnectionState(false);
|
|
193
|
+
|
|
194
|
+
// Stop queue and clear it. Issue a warning if it isn't empty.
|
|
195
|
+
this.runQueue = false;
|
|
196
|
+
if (this.eventsQueue.size() > 0) {
|
|
197
|
+
this.log.warn(`Event queue is not empty. Discarding ${this.eventsQueue.size()} items`);
|
|
198
|
+
}
|
|
199
|
+
// Yes - I know this could go in the 'if' above but here 'just in case' ;)
|
|
200
|
+
this.eventsQueue.clear();
|
|
201
|
+
|
|
202
|
+
if (code != LxCommunicator.SupportCode.WEBSOCKET_MANUAL_CLOSE) {
|
|
203
|
+
this.reconnect();
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
socketOnEventReceived: (socket: any, events: any, type: number) => {
|
|
207
|
+
this.log.silly(`socket event received ${type} ${JSON.stringify(events)}`);
|
|
208
|
+
this.incInfoState('info.messagesReceived');
|
|
209
|
+
for (const evt of events) {
|
|
210
|
+
switch (type) {
|
|
211
|
+
case LxCommunicator.BinaryEvent.Type.EVENT:
|
|
212
|
+
handleAnyEvent(evt.uuid, evt.value);
|
|
213
|
+
break;
|
|
214
|
+
case LxCommunicator.BinaryEvent.Type.EVENTTEXT:
|
|
215
|
+
handleAnyEvent(evt.uuid, evt.text);
|
|
216
|
+
break;
|
|
217
|
+
case LxCommunicator.BinaryEvent.Type.WEATHER:
|
|
218
|
+
handleAnyEvent(evt.uuid, evt);
|
|
219
|
+
break;
|
|
220
|
+
default:
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
this.socket = new LxCommunicator.WebSocket(webSocketConfig);
|
|
227
|
+
|
|
228
|
+
await this.connect();
|
|
229
|
+
|
|
230
|
+
this.subscribeStates('*');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async loadStructureFile(): Promise<boolean> {
|
|
234
|
+
let file: StructureFile;
|
|
235
|
+
try {
|
|
236
|
+
const fileString = await this.socket.send('data/LoxAPP3.json');
|
|
237
|
+
file = JSON.parse(fileString);
|
|
238
|
+
} catch {
|
|
239
|
+
// do not stringify error, it can contain circular references
|
|
240
|
+
this.log.error(`Couldn't get structure file`);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
this.log.silly(`get_structure_file ${JSON.stringify(file)}`);
|
|
244
|
+
this.log.info(`got structure file; last modified on ${file.lastModified}`);
|
|
245
|
+
const sentry = this.getSentry();
|
|
246
|
+
if (sentry) {
|
|
247
|
+
// add a global event processor to upload the structure file (only once)
|
|
248
|
+
sentry.addEventProcessor(this.createSentryEventProcessor(file));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await this.loadStructureFileAsync(file);
|
|
253
|
+
this.log.debug('structure file successfully loaded');
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// do not stringify error, it can contain circular references
|
|
256
|
+
this.log.error(`Couldn't load structure file`);
|
|
257
|
+
sentry?.captureException(error, { extra: { file } });
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return true; // Success
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async connect(): Promise<void> {
|
|
265
|
+
if (this.connectionInProgress) {
|
|
266
|
+
this.log.warn('Connection already in progress');
|
|
267
|
+
} else {
|
|
268
|
+
this.log.info('Trying to connect');
|
|
269
|
+
this.connectionInProgress = true;
|
|
270
|
+
|
|
271
|
+
let success = true; // Assume success
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
await this.socket.open(
|
|
275
|
+
`${this.config.host}:${this.config.port}`,
|
|
276
|
+
this.config.username,
|
|
277
|
+
this.config.password,
|
|
278
|
+
);
|
|
279
|
+
} catch {
|
|
280
|
+
// do not stringify error, it can contain circular references
|
|
281
|
+
this.log.error(`Couldn't open socket`);
|
|
282
|
+
success = false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (success) {
|
|
286
|
+
success = await this.loadStructureFile();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (success) {
|
|
290
|
+
try {
|
|
291
|
+
await this.socket.send('jdev/sps/enablebinstatusupdate');
|
|
292
|
+
} catch {
|
|
293
|
+
// do not stringify error, it can contain circular references
|
|
294
|
+
this.log.error(`Couldn't enable status updates`);
|
|
295
|
+
success = false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.connectionInProgress = false;
|
|
300
|
+
|
|
301
|
+
if (!success) {
|
|
302
|
+
this.log.debug('Connection failed - will retry after delay');
|
|
303
|
+
this.socket.close();
|
|
304
|
+
this.reconnect();
|
|
305
|
+
} else {
|
|
306
|
+
// We are ready, let's set the connection indicator
|
|
307
|
+
this.setConnectionState(true);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private reconnect(): void {
|
|
313
|
+
if (this.reconnectTimer) {
|
|
314
|
+
this.log.debug('Reconnect called while timer already running');
|
|
315
|
+
} else if (this.connectionInProgress) {
|
|
316
|
+
this.log.debug('Reconnect called while connection in progress');
|
|
317
|
+
} else {
|
|
318
|
+
this.reconnectTimer = this.setTimeout(() => {
|
|
319
|
+
delete this.reconnectTimer;
|
|
320
|
+
this.connect().catch(e => {
|
|
321
|
+
this.log.error(`Couldn't reconnect: ${e}`);
|
|
322
|
+
this.reconnect();
|
|
323
|
+
});
|
|
324
|
+
}, reconnectTimeoutMs);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private setConnectionState(connected: boolean): void {
|
|
329
|
+
this.lxConnected = connected;
|
|
330
|
+
this.setState('info.connection', this.lxConnected, true).catch(e => this.log.warn(e));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Is called when adapter shuts down - callback has to be called under any circumstances!
|
|
335
|
+
*
|
|
336
|
+
* @param callback the callback to call when cleanup is done
|
|
337
|
+
*/
|
|
338
|
+
private onUnload(callback: () => void): void {
|
|
339
|
+
try {
|
|
340
|
+
if (this.socket) {
|
|
341
|
+
this.socket.close();
|
|
342
|
+
delete this.socket;
|
|
343
|
+
}
|
|
344
|
+
callback();
|
|
345
|
+
} catch {
|
|
346
|
+
callback();
|
|
347
|
+
}
|
|
348
|
+
this.flushInfoStates();
|
|
349
|
+
// TODO: clear queued state change timers
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Is called if a subscribed state changes.
|
|
354
|
+
*
|
|
355
|
+
* @param id the id of the state
|
|
356
|
+
* @param state the state object
|
|
357
|
+
*/
|
|
358
|
+
private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {
|
|
359
|
+
// Warning: state can be null if it was deleted!
|
|
360
|
+
if (!id || !state || state.ack) {
|
|
361
|
+
// Do nothing
|
|
362
|
+
} else if (id.includes('.info.')) {
|
|
363
|
+
// Ignore info changes
|
|
364
|
+
// TODO: can this be done better by ignoring '.info.' in subscribeStates?
|
|
365
|
+
} else {
|
|
366
|
+
this.log.debug(`stateChange ${id} ${JSON.stringify(state)}`);
|
|
367
|
+
if (!(id in this.stateChangeListeners)) {
|
|
368
|
+
const msg = `Unsupported state change: ${id}`;
|
|
369
|
+
this.log.error(msg);
|
|
370
|
+
if (!this.reportedUnsupportedStateChanges.has(id)) {
|
|
371
|
+
this.reportedUnsupportedStateChanges.add(id);
|
|
372
|
+
const sentry = this.getSentry();
|
|
373
|
+
sentry?.withScope(scope => {
|
|
374
|
+
scope.setExtra('state', state);
|
|
375
|
+
sentry.captureMessage(msg, 'warning');
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
} else if (!this.lxConnected) {
|
|
379
|
+
this.log.warn(`stateChange ${id} while disconnected, discarding`);
|
|
380
|
+
this.incInfoState('info.stateChangesDiscarded');
|
|
381
|
+
} else {
|
|
382
|
+
const stateChangeListener = this.stateChangeListeners[id];
|
|
383
|
+
if (stateChangeListener.ackTimer) {
|
|
384
|
+
// Ack timer is running: we didn't get a reply from the previous command yet
|
|
385
|
+
if (stateChangeListener.queuedVal !== null) {
|
|
386
|
+
// Already a queued state change: we're going to have to discard that and replace with latest
|
|
387
|
+
this.log.warn(
|
|
388
|
+
`State change in progress for ${id}, discarding ${stateChangeListener.queuedVal}`,
|
|
389
|
+
);
|
|
390
|
+
this.incInfoState('info.stateChangesDiscarded');
|
|
391
|
+
} else {
|
|
392
|
+
// Nothing queued, so this will only be delayed (at least for now)
|
|
393
|
+
this.log.warn(`State change in progress for ${id}, delaying ${state.val}`);
|
|
394
|
+
this.incInfoState('info.stateChangesDelayed');
|
|
395
|
+
}
|
|
396
|
+
stateChangeListener.queuedVal = state.val;
|
|
397
|
+
} else {
|
|
398
|
+
// Ack timer is not running, so we're all good to handle this
|
|
399
|
+
await this.handleStateChange(id, stateChangeListener, state.val);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Convert a state value to an integer.
|
|
407
|
+
*
|
|
408
|
+
* @param value The state value to convert.
|
|
409
|
+
* @returns The integer representation of the state value.
|
|
410
|
+
*/
|
|
411
|
+
public convertStateToInt(value: OldStateValue): number {
|
|
412
|
+
return !value ? 0 : parseInt(value.toString());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async handleStateChange(
|
|
416
|
+
id: string,
|
|
417
|
+
stateChangeListener: StateChangeListenEntry,
|
|
418
|
+
val: ioBroker.StateValue,
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
if (stateChangeListener.opts?.convertToInt) {
|
|
421
|
+
// Convert any values to ints within range if necessary.
|
|
422
|
+
val = this.convertStateToInt(val);
|
|
423
|
+
if (stateChangeListener.opts?.minInt !== undefined && val < stateChangeListener.opts.minInt) {
|
|
424
|
+
val = stateChangeListener.opts.minInt;
|
|
425
|
+
}
|
|
426
|
+
if (stateChangeListener.opts?.maxInt !== undefined && val > stateChangeListener.opts.maxInt) {
|
|
427
|
+
val = stateChangeListener.opts.maxInt;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (stateChangeListener.opts?.notIfEqual && this.currentStateValues[id] === val) {
|
|
431
|
+
// new/old values are the same so don't send update.
|
|
432
|
+
// However, ack the state change as we have 'handled' this (by doing nothing)
|
|
433
|
+
this.log.debug(`State value is unchanged, no listener+self-ack: ${id} ${val}`);
|
|
434
|
+
await this.setStateAck(id, val);
|
|
435
|
+
} else {
|
|
436
|
+
if (!stateChangeListener.opts?.selfAck) {
|
|
437
|
+
// Set ack timer before calling listener
|
|
438
|
+
stateChangeListener.ackTimer = this.setTimeout(
|
|
439
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
440
|
+
// @ts-ignore
|
|
441
|
+
async (id: string, stateChangeListener: StateChangeListenEntry) => {
|
|
442
|
+
this.log.warn(`Timeout for ack ${id}`);
|
|
443
|
+
this.incInfoState('info.ackTimeouts', id);
|
|
444
|
+
stateChangeListener.ackTimer = undefined;
|
|
445
|
+
// Even though this is a timeout, handle any change that may have been delayed waiting for this
|
|
446
|
+
await this.handleDelayedStateChange(id, stateChangeListener);
|
|
447
|
+
},
|
|
448
|
+
stateChangeListener.opts?.ackTimeoutMs ? stateChangeListener.opts?.ackTimeoutMs : ackTimeoutMs,
|
|
449
|
+
id,
|
|
450
|
+
stateChangeListener,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Change will be handled by listener
|
|
455
|
+
stateChangeListener.listener(this.currentStateValues[id], val);
|
|
456
|
+
|
|
457
|
+
if (stateChangeListener.opts?.selfAck) {
|
|
458
|
+
// Loxone is not expected to send an event to acknowledge this so just do it ourself
|
|
459
|
+
this.log.debug(`Self-ack: ${id} ${val}`);
|
|
460
|
+
await this.setStateAck(id, val);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async handleDelayedStateChange(id: string, stateChangeListener: StateChangeListenEntry): Promise<void> {
|
|
466
|
+
if (stateChangeListener.queuedVal !== null) {
|
|
467
|
+
this.log.debug(`Handling delayed state: ${id} ${stateChangeListener.queuedVal}`);
|
|
468
|
+
await this.handleStateChange(id, stateChangeListener, stateChangeListener.queuedVal);
|
|
469
|
+
stateChangeListener.queuedVal = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private createSentryEventProcessor(data: StructureFile): EventProcessor {
|
|
474
|
+
const sentry = this.getSentry()!;
|
|
475
|
+
let attachmentEventId: string | undefined;
|
|
476
|
+
return async (event: SentryNode.Event) => {
|
|
477
|
+
try {
|
|
478
|
+
if (attachmentEventId) {
|
|
479
|
+
// structure file was already added
|
|
480
|
+
if (event.breadcrumbs) {
|
|
481
|
+
event.breadcrumbs.push({
|
|
482
|
+
type: 'debug',
|
|
483
|
+
category: 'started',
|
|
484
|
+
message: `Structure file added to event ${attachmentEventId}`,
|
|
485
|
+
level: 'info',
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return event;
|
|
489
|
+
}
|
|
490
|
+
const dsn = sentry.getCurrentHub().getClient()?.getDsn();
|
|
491
|
+
if (!dsn || !event.event_id) {
|
|
492
|
+
return event;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
attachmentEventId = event.event_id;
|
|
496
|
+
|
|
497
|
+
const { host, path, projectId, port, protocol, publicKey } = dsn;
|
|
498
|
+
const endpoint = `${protocol}://${host}${port !== '' ? `:${port}` : ''}${
|
|
499
|
+
path !== '' ? `/${path}` : ''
|
|
500
|
+
}/api/${projectId}/events/${attachmentEventId}/attachments/?sentry_key=${publicKey}&sentry_version=7&sentry_client=custom-javascript`;
|
|
501
|
+
|
|
502
|
+
const form = new FormData();
|
|
503
|
+
form.append('att', JSON.stringify(data, null, 2), {
|
|
504
|
+
contentType: 'application/json',
|
|
505
|
+
filename: 'LoxAPP3.json',
|
|
506
|
+
});
|
|
507
|
+
await axios.post(endpoint, form, { headers: form.getHeaders() });
|
|
508
|
+
return event;
|
|
509
|
+
} catch (ex: any) {
|
|
510
|
+
this.log.error(`Couldn't upload structure file attachment to sentry: ${ex}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return event;
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private async loadStructureFileAsync(data: StructureFile): Promise<void> {
|
|
518
|
+
this.stateEventHandlers = {};
|
|
519
|
+
this.foundRooms = {};
|
|
520
|
+
this.foundCats = {};
|
|
521
|
+
this.operatingModes = data.operatingModes;
|
|
522
|
+
await this.loadGlobalStatesAsync(data.globalStates);
|
|
523
|
+
await this.loadControlsAsync(data.controls);
|
|
524
|
+
await this.loadEnumsAsync(data.rooms, 'enum.rooms', this.foundRooms, this.config.syncRooms);
|
|
525
|
+
await this.loadEnumsAsync(data.cats, 'enum.functions', this.foundCats, this.config.syncFunctions);
|
|
526
|
+
await this.loadWeatherServerAsync(data.weatherServer);
|
|
527
|
+
|
|
528
|
+
// replay all queued events
|
|
529
|
+
this.runQueue = true;
|
|
530
|
+
await this.handleEventQueue();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async loadGlobalStatesAsync(globalStates: GlobalStates): Promise<void> {
|
|
534
|
+
interface GlobalStateInfo {
|
|
535
|
+
type: ioBroker.CommonType;
|
|
536
|
+
role: string;
|
|
537
|
+
handler: (name: string, value: ioBroker.StateValue) => Promise<void>;
|
|
538
|
+
}
|
|
539
|
+
const globalStateInfos: Record<string, GlobalStateInfo> = {
|
|
540
|
+
operatingMode: {
|
|
541
|
+
type: 'number',
|
|
542
|
+
role: 'value',
|
|
543
|
+
handler: this.setOperatingMode.bind(this),
|
|
544
|
+
},
|
|
545
|
+
sunrise: {
|
|
546
|
+
type: 'number',
|
|
547
|
+
role: 'value.interval',
|
|
548
|
+
handler: this.setStateAck.bind(this),
|
|
549
|
+
},
|
|
550
|
+
sunset: {
|
|
551
|
+
type: 'number',
|
|
552
|
+
role: 'value.interval',
|
|
553
|
+
handler: this.setStateAck.bind(this),
|
|
554
|
+
},
|
|
555
|
+
notifications: {
|
|
556
|
+
type: 'number',
|
|
557
|
+
role: 'value',
|
|
558
|
+
handler: this.setStateAck.bind(this),
|
|
559
|
+
},
|
|
560
|
+
modifications: {
|
|
561
|
+
type: 'number',
|
|
562
|
+
role: 'value',
|
|
563
|
+
handler: this.setStateAck.bind(this),
|
|
564
|
+
},
|
|
565
|
+
hasInternet: {
|
|
566
|
+
type: 'boolean',
|
|
567
|
+
role: 'indicator',
|
|
568
|
+
handler: (name, value) => this.setStateAck(name, value === 1),
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
const defaultInfo: GlobalStateInfo = {
|
|
572
|
+
type: 'string',
|
|
573
|
+
role: 'text',
|
|
574
|
+
handler: (name, value) => this.setStateAck(name, `${value}`),
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// special case for operating mode (text)
|
|
578
|
+
await this.updateObjectAsync('operatingMode-text', {
|
|
579
|
+
type: 'state',
|
|
580
|
+
common: {
|
|
581
|
+
name: 'operatingMode: text',
|
|
582
|
+
read: true,
|
|
583
|
+
write: false,
|
|
584
|
+
type: 'string',
|
|
585
|
+
role: 'text',
|
|
586
|
+
},
|
|
587
|
+
native: {
|
|
588
|
+
uuid: globalStates.operatingMode,
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
for (const globalStateName in globalStates) {
|
|
593
|
+
const info = globalStateInfos[globalStateName] ?? defaultInfo;
|
|
594
|
+
await this.updateStateObjectAsync(
|
|
595
|
+
globalStateName,
|
|
596
|
+
{
|
|
597
|
+
name: globalStateName,
|
|
598
|
+
read: true,
|
|
599
|
+
write: false,
|
|
600
|
+
type: info.type,
|
|
601
|
+
role: info.role,
|
|
602
|
+
},
|
|
603
|
+
globalStates[globalStateName],
|
|
604
|
+
info.handler,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private async setOperatingMode(name: string, value: any): Promise<void> {
|
|
610
|
+
await this.setStateAck(name, value);
|
|
611
|
+
await this.setStateAck(`${name}-text`, this.operatingModes[value]);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async loadControlsAsync(controls: Controls): Promise<void> {
|
|
615
|
+
let hasUnsupported = false;
|
|
616
|
+
for (const uuid in controls) {
|
|
617
|
+
const control = controls[uuid];
|
|
618
|
+
if (!('type' in control)) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
await this.loadControlAsync('device', uuid, control);
|
|
624
|
+
} catch (e: any) {
|
|
625
|
+
this.log.info(`Currently unsupported control type ${control.type}: ${e}`);
|
|
626
|
+
this.getSentry()?.captureException(e, { extra: { uuid, control } });
|
|
627
|
+
|
|
628
|
+
if (!hasUnsupported) {
|
|
629
|
+
hasUnsupported = true;
|
|
630
|
+
await this.updateObjectAsync('Unsupported', {
|
|
631
|
+
type: 'device',
|
|
632
|
+
common: {
|
|
633
|
+
name: 'Unsupported',
|
|
634
|
+
role: 'info',
|
|
635
|
+
},
|
|
636
|
+
native: {},
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
await this.updateObjectAsync(`Unsupported.${uuid}`, {
|
|
641
|
+
type: 'state',
|
|
642
|
+
common: {
|
|
643
|
+
name: control.name,
|
|
644
|
+
read: true,
|
|
645
|
+
write: false,
|
|
646
|
+
type: 'string',
|
|
647
|
+
role: 'text',
|
|
648
|
+
},
|
|
649
|
+
native: { control },
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Load sub-controls of a control.
|
|
657
|
+
*
|
|
658
|
+
* @param parentUuid The UUID of the parent control.
|
|
659
|
+
* @param control The control containing sub-controls.
|
|
660
|
+
* @returns A promise that resolves when sub-controls are loaded.
|
|
661
|
+
*/
|
|
662
|
+
public async loadSubControlsAsync(parentUuid: string, control: Control): Promise<void> {
|
|
663
|
+
if (!('subControls' in control)) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
for (let uuid in control.subControls) {
|
|
667
|
+
const subControl = control.subControls[uuid];
|
|
668
|
+
if (!('type' in subControl)) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
if (uuid.startsWith(`${parentUuid}/`)) {
|
|
674
|
+
uuid = uuid.replace('/', '.');
|
|
675
|
+
} else {
|
|
676
|
+
uuid = `${parentUuid}.${uuid.replace('/', '-')}`;
|
|
677
|
+
}
|
|
678
|
+
subControl.name = `${control.name}: ${subControl.name}`;
|
|
679
|
+
|
|
680
|
+
await this.loadControlAsync('channel', uuid, subControl);
|
|
681
|
+
} catch (e: any) {
|
|
682
|
+
this.log.info(`Currently unsupported sub-control type ${subControl.type}: ${e}`);
|
|
683
|
+
this.getSentry()?.captureException(e, { extra: { uuid, subControl } });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private async loadControlAsync(controlType: ControlType, uuid: string, control: Control): Promise<void> {
|
|
689
|
+
const type = control.type || 'None';
|
|
690
|
+
if (type.match(/[^a-z0-9]/i)) {
|
|
691
|
+
throw new Error(`Bad control type: ${type}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
let controlObject: ControlBase;
|
|
695
|
+
try {
|
|
696
|
+
controlObject = new AllControls[type as keyof typeof AllControls](this);
|
|
697
|
+
} catch (e: any) {
|
|
698
|
+
this.log.silly(`Couldn't load control type ${type}: ${e}`);
|
|
699
|
+
controlObject = new Unknown(this);
|
|
700
|
+
}
|
|
701
|
+
await controlObject.loadAsync(controlType, uuid, control);
|
|
702
|
+
|
|
703
|
+
if ('room' in control) {
|
|
704
|
+
if (!this.foundRooms[control.room]) {
|
|
705
|
+
this.foundRooms[control.room] = [];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
this.foundRooms[control.room].push(uuid);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if ('cat' in control) {
|
|
712
|
+
if (!this.foundCats[control.cat]) {
|
|
713
|
+
this.foundCats[control.cat] = [];
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
this.foundCats[control.cat].push(uuid);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private async loadEnumsAsync(
|
|
721
|
+
values: Record<string, any>,
|
|
722
|
+
enumName: string,
|
|
723
|
+
found: Record<string, string[]>,
|
|
724
|
+
enabled: boolean,
|
|
725
|
+
): Promise<void> {
|
|
726
|
+
if (!enabled) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
for (const uuid in values) {
|
|
731
|
+
if (!found[uuid]) {
|
|
732
|
+
// don't sync room/cat if we have no control that is using it
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const members = [];
|
|
737
|
+
for (const id of found[uuid]) {
|
|
738
|
+
members.push(`${this.namespace}.${id}`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const item = values[uuid];
|
|
742
|
+
const name = item.name.replace(/[\][*.,;'"`<>\\?]+/g, '_');
|
|
743
|
+
const obj = {
|
|
744
|
+
type: 'enum',
|
|
745
|
+
common: {
|
|
746
|
+
name: name,
|
|
747
|
+
members: members,
|
|
748
|
+
},
|
|
749
|
+
native: item,
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
await this.updateEnumObjectAsync(`${enumName}.${name}`, obj);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private async updateEnumObjectAsync(id: string, newObj: any): Promise<void> {
|
|
757
|
+
// TODO: the parameter newObj should be an EnumObject, but currently that doesn't exist in the type definition
|
|
758
|
+
// similar to hm-rega.js:
|
|
759
|
+
let obj: any = await this.getForeignObjectAsync(id);
|
|
760
|
+
let changed = false;
|
|
761
|
+
if (!obj) {
|
|
762
|
+
obj = newObj;
|
|
763
|
+
changed = true;
|
|
764
|
+
} else if (newObj && newObj.common && newObj.common.members) {
|
|
765
|
+
obj.common = obj.common || {};
|
|
766
|
+
obj.common.members = obj.common.members || [];
|
|
767
|
+
for (let m = 0; m < newObj.common.members.length; m++) {
|
|
768
|
+
if (obj.common.members.indexOf(newObj.common.members[m]) === -1) {
|
|
769
|
+
changed = true;
|
|
770
|
+
obj.common.members.push(newObj.common.members[m]);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (changed) {
|
|
775
|
+
await this.setForeignObjectAsync(id, obj);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private async loadWeatherServerAsync(data: WeatherServer): Promise<void> {
|
|
780
|
+
if (this.config.weatherServer === 'off') {
|
|
781
|
+
this.log.debug('WeatherServer is disabled in the adapter configuration');
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const handler = new WeatherServerHandler(this);
|
|
785
|
+
await handler.loadAsync(data, this.config.weatherServer || 'all');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private async handleEventQueue(): Promise<void> {
|
|
789
|
+
// TODO: This solution with globals for runQueue & queueRunning
|
|
790
|
+
// isn't very elegant. It works, but is there a better way?
|
|
791
|
+
if (!this.runQueue) {
|
|
792
|
+
this.log.silly('Asked to handle the queue, but is stopped');
|
|
793
|
+
} else if (this.queueRunning) {
|
|
794
|
+
this.log.silly('Asked to handle the queue, but already in progress');
|
|
795
|
+
} else {
|
|
796
|
+
this.queueRunning = true;
|
|
797
|
+
this.log.silly(`Processing events from queue length: ${this.eventsQueue.size()}`);
|
|
798
|
+
let evt: LoxoneEvent | null;
|
|
799
|
+
while ((evt = this.eventsQueue.dequeue())) {
|
|
800
|
+
this.log.silly(`Dequeued event UUID: ${evt.uuid}`);
|
|
801
|
+
await this.handleEvent(evt);
|
|
802
|
+
}
|
|
803
|
+
this.queueRunning = false;
|
|
804
|
+
this.log.silly('Done with event queue');
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private async handleEvent(evt: LoxoneEvent): Promise<void> {
|
|
809
|
+
const stateEventHandlerList = this.stateEventHandlers[evt.uuid];
|
|
810
|
+
if (stateEventHandlerList === undefined) {
|
|
811
|
+
this.log.debug(`Unknown event ${evt.uuid}: ${JSON.stringify(evt.evt)}`);
|
|
812
|
+
this.incInfoState('info.unknownEvents', evt.uuid, evt.evt);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
for (const item of stateEventHandlerList) {
|
|
817
|
+
try {
|
|
818
|
+
await item.handler(evt.evt);
|
|
819
|
+
} catch (e: any) {
|
|
820
|
+
this.log.error(`Error while handling event UUID ${evt.uuid}: ${e}`);
|
|
821
|
+
this.getSentry()?.captureException(e, { extra: { evt } });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private async initInfoStates(): Promise<void> {
|
|
827
|
+
// Wait for states to load because if we don't, although the chances
|
|
828
|
+
// of processing starting before this actually completes is small, we
|
|
829
|
+
// should cater for that.
|
|
830
|
+
await this.initInfoState('info.ackTimeouts', true);
|
|
831
|
+
await this.initInfoState('info.messagesReceived');
|
|
832
|
+
await this.initInfoState('info.messagesSent');
|
|
833
|
+
await this.initInfoState('info.stateChangesDelayed');
|
|
834
|
+
await this.initInfoState('info.stateChangesDiscarded');
|
|
835
|
+
await this.initInfoState('info.unknownEvents', true);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private async initInfoState(id: string, hasDetails = false): Promise<void> {
|
|
839
|
+
const state = await this.getStateAsync(id);
|
|
840
|
+
const initValue = state ? state.val : null;
|
|
841
|
+
const entry: InfoEntry = {
|
|
842
|
+
value: initValue,
|
|
843
|
+
lastSet: initValue,
|
|
844
|
+
timer: undefined,
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
if (hasDetails) {
|
|
848
|
+
// TODO: Maybe read these in so they persist across restarts?
|
|
849
|
+
entry.detailsMap = new Map<string, InfoDetailsEntry>();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
this.info.set(id, entry);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private flushInfoStates(): void {
|
|
856
|
+
// Called on shutdown
|
|
857
|
+
this.info.forEach((infoEntry, key) => {
|
|
858
|
+
if (infoEntry.timer) {
|
|
859
|
+
// Timer running, so cancel it and update state value if changed since last written
|
|
860
|
+
this.clearTimeout(infoEntry.timer);
|
|
861
|
+
this.setInfoStateIfChanged(key, infoEntry, true);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private getInfoEntry(id: string): InfoEntry | undefined {
|
|
867
|
+
const infoEntry = this.info.get(id);
|
|
868
|
+
if (!infoEntry) {
|
|
869
|
+
// This should never happen!
|
|
870
|
+
this.log.error(`No info entry for ${id}`);
|
|
871
|
+
}
|
|
872
|
+
return infoEntry;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private addInfoDetailsEntry(details: InfoDetailsEntryMap, id: string, value?: any): void {
|
|
876
|
+
/// ... and add details of this event to the map.
|
|
877
|
+
const eventEntry = details.get(id);
|
|
878
|
+
if (eventEntry) {
|
|
879
|
+
// Add to existing
|
|
880
|
+
eventEntry.count++;
|
|
881
|
+
if (value !== undefined) {
|
|
882
|
+
eventEntry.lastValue = value;
|
|
883
|
+
}
|
|
884
|
+
} else {
|
|
885
|
+
// New entry
|
|
886
|
+
if (value !== undefined) {
|
|
887
|
+
details.set(id, { count: 1, lastValue: value });
|
|
888
|
+
} else {
|
|
889
|
+
details.set(id, { count: 1 });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private incInfoState(id: string, detailId?: string, detailValue?: any): void {
|
|
895
|
+
// Increment the given ID
|
|
896
|
+
const infoEntry = this.getInfoEntry(id);
|
|
897
|
+
if (infoEntry) {
|
|
898
|
+
// Can't use ++ here because ioBroker.StateValue isn't necessarily a number
|
|
899
|
+
infoEntry.value = Number(infoEntry.value) + 1;
|
|
900
|
+
// If value given and this entry has details record that
|
|
901
|
+
if (infoEntry.detailsMap && detailId) {
|
|
902
|
+
this.addInfoDetailsEntry(infoEntry.detailsMap, detailId, detailValue);
|
|
903
|
+
}
|
|
904
|
+
if (!infoEntry.timer) {
|
|
905
|
+
this.setInfoStateIfChanged(id, infoEntry);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private buildInfoDetails(src: InfoDetailsEntryMap): string {
|
|
911
|
+
// TODO: shouldn't this use JSON.stringify?
|
|
912
|
+
const out: any[] = [];
|
|
913
|
+
src.forEach((value, key) => {
|
|
914
|
+
if (value.lastValue !== undefined) {
|
|
915
|
+
out.push({ id: key, count: value.count, lastValue: value.lastValue });
|
|
916
|
+
} else {
|
|
917
|
+
out.push({ id: key, count: value.count });
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
return JSON.stringify(out);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private setInfoStateIfChanged(id: string, infoEntry: InfoEntry, shutdown = false): void {
|
|
924
|
+
if (infoEntry.value != infoEntry.lastSet) {
|
|
925
|
+
this.log.silly(`value of ${id} changed to ${infoEntry.value}`);
|
|
926
|
+
|
|
927
|
+
// Store counter
|
|
928
|
+
this.setState(id, infoEntry.value, true).catch(e => this.log.warn(e));
|
|
929
|
+
infoEntry.lastSet = infoEntry.value;
|
|
930
|
+
|
|
931
|
+
// Store any details
|
|
932
|
+
if (infoEntry.detailsMap) {
|
|
933
|
+
this.setState(`${id}Detail`, this.buildInfoDetails(infoEntry.detailsMap), true).catch(e =>
|
|
934
|
+
this.log.warn(e),
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (!shutdown) {
|
|
939
|
+
// Start a timer which will set the current value from the info ID map on completion
|
|
940
|
+
// Obviously don't do this if called from shutdown
|
|
941
|
+
this.log.silly(`Starting timer for ${id}`);
|
|
942
|
+
infoEntry.timer = this.setTimeout(
|
|
943
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
944
|
+
// @ts-ignore
|
|
945
|
+
(cbId: string, cbInfoEntry: InfoEntry) => {
|
|
946
|
+
this.log.silly(`Timeout for ${id}`);
|
|
947
|
+
|
|
948
|
+
// Remove from timer from map as we have just finished
|
|
949
|
+
cbInfoEntry.timer = undefined;
|
|
950
|
+
|
|
951
|
+
// Update the state, but only if the value in the info ID map has changed
|
|
952
|
+
this.setInfoStateIfChanged(cbId, cbInfoEntry);
|
|
953
|
+
},
|
|
954
|
+
30000, // Update every 30s max TODO: make this a config parameter?
|
|
955
|
+
id,
|
|
956
|
+
infoEntry, // Pass reference to entry
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Sends a command to the Loxone Miniserver.
|
|
964
|
+
*
|
|
965
|
+
* @param uuid The UUID of the command.
|
|
966
|
+
* @param action The name of the action to send.
|
|
967
|
+
*/
|
|
968
|
+
public sendCommand(uuid: string, action: string): void {
|
|
969
|
+
this.log.debug(`Sending command ${uuid} ${action}`);
|
|
970
|
+
this.incInfoState('info.messagesSent');
|
|
971
|
+
this.socket.send(`jdev/sps/io/${uuid}/${action}`, 2);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Get an existing object by its ID.
|
|
976
|
+
*
|
|
977
|
+
* @param id The local ID of the object (without namespace).
|
|
978
|
+
* @returns The existing object or undefined if not found.
|
|
979
|
+
*/
|
|
980
|
+
public getExistingObject(id: string): ioBroker.Object | undefined {
|
|
981
|
+
const fullId = `${this.namespace}.${id}`;
|
|
982
|
+
return this.existingObjects[fullId];
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Update or create an object asynchronously.
|
|
987
|
+
*
|
|
988
|
+
* @param id The local ID of the object (without namespace).
|
|
989
|
+
* @param obj The object to update or create.
|
|
990
|
+
*/
|
|
991
|
+
public async updateObjectAsync(id: string, obj: ioBroker.SettableObject): Promise<void> {
|
|
992
|
+
const fullId = `${this.namespace}.${id}`;
|
|
993
|
+
if (this.existingObjects[fullId]) {
|
|
994
|
+
const existingObject = this.existingObjects[fullId];
|
|
995
|
+
if (!this.config.syncNames && obj.common) {
|
|
996
|
+
obj.common.name = existingObject.common.name;
|
|
997
|
+
}
|
|
998
|
+
/* TODO: re-add:
|
|
999
|
+
if (obj.common.smartName != 'ignore' && existingObject.common.smartName != 'ignore') {
|
|
1000
|
+
// keep the smartName (if it's not supposed to be ignored)
|
|
1001
|
+
obj.common.smartName = existingObject.common.smartName;
|
|
1002
|
+
}*/
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
await this.extendObjectAsync(id, obj);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Update a state object asynchronously.
|
|
1010
|
+
*
|
|
1011
|
+
* @param id The local ID of the state (without namespace).
|
|
1012
|
+
* @param commonInfo The common information for the state.
|
|
1013
|
+
* @param stateUuid The UUID of the state.
|
|
1014
|
+
* @param stateEventHandler An optional event handler for state changes.
|
|
1015
|
+
*/
|
|
1016
|
+
public async updateStateObjectAsync(
|
|
1017
|
+
id: string,
|
|
1018
|
+
commonInfo: ioBroker.StateCommon,
|
|
1019
|
+
stateUuid: string,
|
|
1020
|
+
stateEventHandler?: NamedStateEventHandler,
|
|
1021
|
+
): Promise<void> {
|
|
1022
|
+
/* TODO: re-add:
|
|
1023
|
+
if (commonInfo.hasOwnProperty('smartIgnore')) {
|
|
1024
|
+
// interpret smartIgnore (our own extension of common) to generate smartName if needed
|
|
1025
|
+
if (commonInfo.smartIgnore) {
|
|
1026
|
+
commonInfo.smartName = 'ignore';
|
|
1027
|
+
} else if (!commonInfo.hasOwnProperty('smartName')) {
|
|
1028
|
+
commonInfo.smartName = null;
|
|
1029
|
+
}
|
|
1030
|
+
delete commonInfo.smartIgnore;
|
|
1031
|
+
}*/
|
|
1032
|
+
const obj: ioBroker.SettableObjectWorker<ioBroker.StateObject> = {
|
|
1033
|
+
type: 'state',
|
|
1034
|
+
common: commonInfo,
|
|
1035
|
+
native: {
|
|
1036
|
+
uuid: stateUuid,
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
await this.updateObjectAsync(id, obj);
|
|
1040
|
+
if (stateEventHandler) {
|
|
1041
|
+
this.addStateEventHandler(stateUuid, async (value: ioBroker.StateValue) => {
|
|
1042
|
+
await stateEventHandler(id, value);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Add a state event handler for a given UUID.
|
|
1049
|
+
*
|
|
1050
|
+
* @param uuid The UUID of the control.
|
|
1051
|
+
* @param eventHandler The event handler function.
|
|
1052
|
+
* @param name An optional name for the event handler.
|
|
1053
|
+
*/
|
|
1054
|
+
public addStateEventHandler(uuid: string, eventHandler: StateEventHandler, name?: string): void {
|
|
1055
|
+
if (this.stateEventHandlers[uuid] === undefined) {
|
|
1056
|
+
this.stateEventHandlers[uuid] = [];
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (name) {
|
|
1060
|
+
this.removeStateEventHandler(uuid, name);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
this.stateEventHandlers[uuid].push({ name: name, handler: eventHandler });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Remove a state event handler for a given UUID by name.
|
|
1068
|
+
*
|
|
1069
|
+
* @param uuid The UUID of the control.
|
|
1070
|
+
* @param name The name of the event handler to remove.
|
|
1071
|
+
* @returns True if the handler was found and removed, false otherwise.
|
|
1072
|
+
*/
|
|
1073
|
+
public removeStateEventHandler(uuid: string, name: string): boolean {
|
|
1074
|
+
if (this.stateEventHandlers[uuid] === undefined || !name) {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
let found = false;
|
|
1079
|
+
for (let i = 0; i < this.stateEventHandlers[uuid].length; i++) {
|
|
1080
|
+
if (this.stateEventHandlers[uuid][i].name === name) {
|
|
1081
|
+
this.stateEventHandlers[uuid].splice(i, 1);
|
|
1082
|
+
found = true;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return found;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Add a state change listener for a given state ID.
|
|
1091
|
+
*
|
|
1092
|
+
* @param id The local ID of the state (without namespace).
|
|
1093
|
+
* @param listener The state change listener function.
|
|
1094
|
+
* @param opts Optional options for the state change listener.
|
|
1095
|
+
*/
|
|
1096
|
+
public addStateChangeListener(id: string, listener: StateChangeListener, opts?: StateChangeListenerOpts): void {
|
|
1097
|
+
this.stateChangeListeners[`${this.namespace}.${id}`] = {
|
|
1098
|
+
listener,
|
|
1099
|
+
opts,
|
|
1100
|
+
queuedVal: null,
|
|
1101
|
+
ackTimer: undefined,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private async checkStateForAck(id: string): Promise<void> {
|
|
1106
|
+
const stateChangeListener = this.stateChangeListeners[id];
|
|
1107
|
+
if (stateChangeListener) {
|
|
1108
|
+
// This state change could be a result of a command we sent being ack'd
|
|
1109
|
+
if (stateChangeListener.ackTimer) {
|
|
1110
|
+
// Timer is running so clear it
|
|
1111
|
+
this.log.debug(`Clearing ackTimer for ${id}`);
|
|
1112
|
+
this.clearTimeout(stateChangeListener.ackTimer);
|
|
1113
|
+
stateChangeListener.ackTimer = undefined;
|
|
1114
|
+
// Send any command that may have been delayed waiting for this ack
|
|
1115
|
+
await this.handleDelayedStateChange(id, stateChangeListener);
|
|
1116
|
+
} else {
|
|
1117
|
+
this.log.debug(`No ackTimer for ${id}`);
|
|
1118
|
+
}
|
|
1119
|
+
} else {
|
|
1120
|
+
this.log.silly(`${id} has no stateChangeListener`);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Set state with ack = true and update cached value.
|
|
1126
|
+
*
|
|
1127
|
+
* @param id The local ID of the state (without namespace).
|
|
1128
|
+
* @param value The value to set.
|
|
1129
|
+
*/
|
|
1130
|
+
public async setStateAck(id: string, value: CurrentStateValue): Promise<void> {
|
|
1131
|
+
const keyId = `${this.namespace}.${id}`;
|
|
1132
|
+
this.currentStateValues[keyId] = value;
|
|
1133
|
+
await this.checkStateForAck(keyId);
|
|
1134
|
+
await this.setStateAsync(id, { val: value, ack: true });
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Get a cached state value.
|
|
1139
|
+
*
|
|
1140
|
+
* @param id The local ID of the state (without namespace).
|
|
1141
|
+
* @returns The cached state value.
|
|
1142
|
+
*/
|
|
1143
|
+
public getCachedStateValue(id: string): OldStateValue {
|
|
1144
|
+
const keyId = `${this.namespace}.${id}`;
|
|
1145
|
+
return this.currentStateValues[keyId];
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Get the Sentry instance if available.
|
|
1150
|
+
*
|
|
1151
|
+
* @returns The Sentry instance or undefined if not available.
|
|
1152
|
+
*/
|
|
1153
|
+
public getSentry(): Sentry | undefined {
|
|
1154
|
+
if (this.supportsFeature && this.supportsFeature('PLUGINS')) {
|
|
1155
|
+
const sentryInstance = this.getPluginInstance('sentry');
|
|
1156
|
+
if (sentryInstance) {
|
|
1157
|
+
return sentryInstance.getSentryObject();
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Report an error message to Sentry.
|
|
1164
|
+
*
|
|
1165
|
+
* @param message The error message to report.
|
|
1166
|
+
*/
|
|
1167
|
+
public reportError(message: string): void {
|
|
1168
|
+
this.log.error(message);
|
|
1169
|
+
this.getSentry()?.captureMessage(message, 'error');
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (require.main !== module) {
|
|
1173
|
+
// Export the constructor in compact mode
|
|
1174
|
+
module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Loxone(options);
|
|
1175
|
+
} else {
|
|
1176
|
+
// otherwise start the instance directly
|
|
1177
|
+
(() => new Loxone())();
|
|
1178
|
+
}
|