iobroker.fairland 0.2.1
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 +23 -0
- package/README.md +219 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/admin/fairland.png +0 -0
- package/admin/i18n/de/translations.json +9 -0
- package/admin/i18n/en/translations.json +9 -0
- package/admin/i18n/es/translations.json +9 -0
- package/admin/i18n/fr/translations.json +9 -0
- package/admin/i18n/it/translations.json +9 -0
- package/admin/i18n/nl/translations.json +9 -0
- package/admin/i18n/pl/translations.json +9 -0
- package/admin/i18n/pt/translations.json +9 -0
- package/admin/i18n/ru/translations.json +9 -0
- package/admin/i18n/uk/translations.json +9 -0
- package/admin/i18n/zh-cn/translations.json +9 -0
- package/admin/jsonConfig.json +59 -0
- package/build/dpUtils.js +186 -0
- package/build/fairlandApi.js +190 -0
- package/build/main.js +757 -0
- package/build/mappings.js +427 -0
- package/build/types.js +2 -0
- package/io-package.json +228 -0
- package/package.json +63 -0
package/build/main.js
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const utils = __importStar(require("@iobroker/adapter-core"));
|
|
37
|
+
const fairlandApi_1 = require("./fairlandApi");
|
|
38
|
+
const dpUtils_1 = require("./dpUtils");
|
|
39
|
+
const mappings_1 = require("./mappings");
|
|
40
|
+
const WRITE_REFRESH_DELAY_MS = 5_000;
|
|
41
|
+
const PENDING_WRITE_TIMEOUT_MS = 30_000;
|
|
42
|
+
const DEFAULT_SCAN_INTERVAL_SECONDS = 30;
|
|
43
|
+
const MIN_SCAN_INTERVAL_SECONDS = 10;
|
|
44
|
+
class FairlandAdapter extends utils.Adapter {
|
|
45
|
+
apiClient;
|
|
46
|
+
courtyardId;
|
|
47
|
+
pollTimer;
|
|
48
|
+
writeRefreshTimer;
|
|
49
|
+
pendingWrites = new Map();
|
|
50
|
+
writableStates = new Map();
|
|
51
|
+
ensuredObjects = new Set();
|
|
52
|
+
deviceObjectIds = new Map();
|
|
53
|
+
isUnloading = false;
|
|
54
|
+
isPolling = false;
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
super({
|
|
57
|
+
...options,
|
|
58
|
+
name: 'fairland',
|
|
59
|
+
});
|
|
60
|
+
this.on('ready', this.onReady.bind(this));
|
|
61
|
+
this.on('stateChange', this.onStateChange.bind(this));
|
|
62
|
+
this.on('unload', this.onUnload.bind(this));
|
|
63
|
+
}
|
|
64
|
+
async onReady() {
|
|
65
|
+
const config = this.config;
|
|
66
|
+
const username = String(config.accountName ?? '').trim();
|
|
67
|
+
const password = String(config.password ?? '');
|
|
68
|
+
await this.setConnectionState(false);
|
|
69
|
+
if (!username || !password) {
|
|
70
|
+
this.log.warn('Please configure your iGarden account e-mail and password.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const scanIntervalSeconds = this.getScanIntervalSeconds(config);
|
|
74
|
+
this.apiClient = new fairlandApi_1.FairlandApiClient({
|
|
75
|
+
username,
|
|
76
|
+
password,
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
const region = await this.apiClient.detectRegion();
|
|
80
|
+
this.log.info(`Connected to Fairland iGarden API region '${region}'.`);
|
|
81
|
+
await this.setStateAsync('info.region', { val: region, ack: true });
|
|
82
|
+
const courtyards = await this.apiClient.getCourtyards();
|
|
83
|
+
this.courtyardId = this.selectCourtyard(courtyards, String(config.courtyardId ?? '').trim());
|
|
84
|
+
await this.setStateAsync('info.courtyard', { val: this.courtyardId, ack: true });
|
|
85
|
+
await this.extendObjectAsync('devices', {
|
|
86
|
+
type: 'channel',
|
|
87
|
+
common: { name: 'Devices' },
|
|
88
|
+
native: {},
|
|
89
|
+
});
|
|
90
|
+
this.ensuredObjects.add('devices');
|
|
91
|
+
this.subscribeStates('devices.*');
|
|
92
|
+
await this.pollDevices();
|
|
93
|
+
this.pollTimer = this.setInterval(() => void this.pollDevices(), scanIntervalSeconds * 1000);
|
|
94
|
+
this.log.info(`Polling Fairland devices every ${scanIntervalSeconds} seconds.`);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await this.setConnectionState(false);
|
|
98
|
+
this.log.error(`Adapter startup failed: ${this.errorMessage(error)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async onStateChange(id, state) {
|
|
102
|
+
if (this.isUnloading || !state || state.ack) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const localId = this.toLocalId(id);
|
|
106
|
+
const mapping = this.writableStates.get(localId);
|
|
107
|
+
if (!mapping) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!this.apiClient) {
|
|
111
|
+
this.log.warn(`Ignoring write to ${localId}: API client is not ready.`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await this.handleWritableState(localId, mapping, state.val);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
this.log.error(`Failed to write ${localId}: ${this.errorMessage(error)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
onUnload(callback) {
|
|
122
|
+
this.isUnloading = true;
|
|
123
|
+
if (this.pollTimer) {
|
|
124
|
+
this.clearInterval(this.pollTimer);
|
|
125
|
+
this.pollTimer = undefined;
|
|
126
|
+
}
|
|
127
|
+
if (this.writeRefreshTimer) {
|
|
128
|
+
this.clearTimeout(this.writeRefreshTimer);
|
|
129
|
+
this.writeRefreshTimer = undefined;
|
|
130
|
+
}
|
|
131
|
+
void this.setConnectionState(false)
|
|
132
|
+
.catch(error => this.log.debug(`Failed to update connection state on unload: ${error}`))
|
|
133
|
+
.finally(callback);
|
|
134
|
+
}
|
|
135
|
+
async pollDevices() {
|
|
136
|
+
if (!this.apiClient || !this.courtyardId || this.isPolling) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.isPolling = true;
|
|
140
|
+
try {
|
|
141
|
+
const devices = await this.loadDevices(this.courtyardId);
|
|
142
|
+
for (const device of devices) {
|
|
143
|
+
await this.ensureDeviceObjects(device);
|
|
144
|
+
await this.updateDeviceStates(device);
|
|
145
|
+
}
|
|
146
|
+
await this.setConnectionState(true);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
await this.setConnectionState(false);
|
|
150
|
+
this.log.warn(`Polling Fairland devices failed: ${this.errorMessage(error)}`);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
this.isPolling = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async loadDevices(courtyardId) {
|
|
157
|
+
if (!this.apiClient) {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
const devices = await this.apiClient.getAllDevicesInCourtyard(courtyardId);
|
|
161
|
+
const updatedDevices = [];
|
|
162
|
+
for (const device of devices) {
|
|
163
|
+
try {
|
|
164
|
+
const dps = await this.apiClient.getDeviceStatus(device.id);
|
|
165
|
+
updatedDevices.push({ ...device, dps });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
this.log.warn(`Could not fetch status for ${device.deviceName ?? device.id}: ${this.errorMessage(error)}`);
|
|
169
|
+
updatedDevices.push(device);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return updatedDevices;
|
|
173
|
+
}
|
|
174
|
+
async ensureDeviceObjects(device) {
|
|
175
|
+
const deviceBase = this.deviceBase(device);
|
|
176
|
+
await this.ensureDevice(deviceBase, device);
|
|
177
|
+
await this.ensureState(`${deviceBase}.info.name`, {
|
|
178
|
+
name: 'Device name',
|
|
179
|
+
type: 'string',
|
|
180
|
+
role: 'text',
|
|
181
|
+
read: true,
|
|
182
|
+
write: false,
|
|
183
|
+
});
|
|
184
|
+
await this.ensureState(`${deviceBase}.info.category`, {
|
|
185
|
+
name: 'Device category',
|
|
186
|
+
type: 'string',
|
|
187
|
+
role: 'text',
|
|
188
|
+
read: true,
|
|
189
|
+
write: false,
|
|
190
|
+
});
|
|
191
|
+
await this.ensureState(`${deviceBase}.info.version`, {
|
|
192
|
+
name: 'Firmware version',
|
|
193
|
+
type: 'string',
|
|
194
|
+
role: 'text',
|
|
195
|
+
read: true,
|
|
196
|
+
write: false,
|
|
197
|
+
});
|
|
198
|
+
const dpMap = this.dpMap(device);
|
|
199
|
+
if (device.categoryCode === mappings_1.HEAT_PUMP_CATEGORY_CODE) {
|
|
200
|
+
await this.ensureHeatPumpStates(device, dpMap);
|
|
201
|
+
}
|
|
202
|
+
else if (device.categoryCode === mappings_1.WATER_PUMP_CATEGORY_CODE) {
|
|
203
|
+
await this.ensureWaterPumpStates(device, dpMap);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
this.log.debug(`Skipping unsupported Fairland category '${device.categoryCode}' for ${device.deviceName}.`);
|
|
207
|
+
}
|
|
208
|
+
if (this.config.createRawStates) {
|
|
209
|
+
await this.ensureRawStates(device, dpMap);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async ensureHeatPumpStates(device, dpMap) {
|
|
213
|
+
const deviceBase = this.deviceBase(device);
|
|
214
|
+
const powerDp = dpMap.get(mappings_1.HEAT_PUMP_POWER_DP_ID);
|
|
215
|
+
if (powerDp) {
|
|
216
|
+
const stateId = `${deviceBase}.power`;
|
|
217
|
+
await this.ensureState(stateId, {
|
|
218
|
+
name: 'Power',
|
|
219
|
+
type: 'boolean',
|
|
220
|
+
role: 'switch',
|
|
221
|
+
read: true,
|
|
222
|
+
write: powerDp.dpMode === 'rw',
|
|
223
|
+
});
|
|
224
|
+
if (powerDp.dpMode === 'rw') {
|
|
225
|
+
this.writableStates.set(stateId, {
|
|
226
|
+
deviceId: device.id,
|
|
227
|
+
dpId: mappings_1.HEAT_PUMP_POWER_DP_ID,
|
|
228
|
+
kind: 'heatPower',
|
|
229
|
+
valueType: 'boolean',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const modeDp = dpMap.get(mappings_1.HEAT_PUMP_HVAC_MODE_DP_ID);
|
|
234
|
+
if (modeDp) {
|
|
235
|
+
const stateId = `${deviceBase}.mode`;
|
|
236
|
+
await this.ensureState(stateId, {
|
|
237
|
+
name: 'Mode',
|
|
238
|
+
type: 'string',
|
|
239
|
+
role: 'level.mode',
|
|
240
|
+
read: true,
|
|
241
|
+
write: modeDp.dpMode === 'rw',
|
|
242
|
+
states: mappings_1.HEAT_HVAC_MODE_STATES,
|
|
243
|
+
});
|
|
244
|
+
if (modeDp.dpMode === 'rw') {
|
|
245
|
+
this.writableStates.set(stateId, {
|
|
246
|
+
deviceId: device.id,
|
|
247
|
+
dpId: mappings_1.HEAT_PUMP_HVAC_MODE_DP_ID,
|
|
248
|
+
kind: 'heatHvacMode',
|
|
249
|
+
optionToRaw: (0, dpUtils_1.invertEnum)(mappings_1.HEAT_HVAC_MODES),
|
|
250
|
+
valueType: 'string',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const targetDp = dpMap.get(mappings_1.HEAT_PUMP_TARGET_TEMP_DP_ID);
|
|
255
|
+
if (targetDp) {
|
|
256
|
+
const scale = (0, dpUtils_1.getDpScale)(targetDp, 0);
|
|
257
|
+
const stateId = `${deviceBase}.temperature.target`;
|
|
258
|
+
await this.ensureState(stateId, {
|
|
259
|
+
name: 'Target temperature',
|
|
260
|
+
type: 'number',
|
|
261
|
+
role: 'level.temperature',
|
|
262
|
+
unit: '°C',
|
|
263
|
+
read: true,
|
|
264
|
+
write: targetDp.dpMode === 'rw',
|
|
265
|
+
min: 8,
|
|
266
|
+
max: 40,
|
|
267
|
+
step: 1,
|
|
268
|
+
});
|
|
269
|
+
if (targetDp.dpMode === 'rw') {
|
|
270
|
+
this.writableStates.set(stateId, {
|
|
271
|
+
deviceId: device.id,
|
|
272
|
+
dpId: mappings_1.HEAT_PUMP_TARGET_TEMP_DP_ID,
|
|
273
|
+
kind: 'heatTargetTemperature',
|
|
274
|
+
scale,
|
|
275
|
+
valueType: 'number',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const presetDp = dpMap.get(mappings_1.HEAT_PUMP_PRESET_DP_ID);
|
|
280
|
+
if (presetDp) {
|
|
281
|
+
const options = this.parseHeatPresetOptions(presetDp);
|
|
282
|
+
const stateId = `${deviceBase}.presetMode`;
|
|
283
|
+
await this.ensureState(stateId, {
|
|
284
|
+
name: 'Preset mode',
|
|
285
|
+
type: 'string',
|
|
286
|
+
role: 'level.mode',
|
|
287
|
+
read: true,
|
|
288
|
+
write: presetDp.dpMode === 'rw',
|
|
289
|
+
states: (0, dpUtils_1.toStatesObject)(options),
|
|
290
|
+
});
|
|
291
|
+
if (presetDp.dpMode === 'rw') {
|
|
292
|
+
this.writableStates.set(stateId, {
|
|
293
|
+
deviceId: device.id,
|
|
294
|
+
dpId: mappings_1.HEAT_PUMP_PRESET_DP_ID,
|
|
295
|
+
kind: 'heatPresetMode',
|
|
296
|
+
optionToRaw: (0, dpUtils_1.invertEnum)(options),
|
|
297
|
+
valueType: 'string',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
await this.ensureState(`${deviceBase}.hvac.action`, {
|
|
302
|
+
name: 'HVAC action',
|
|
303
|
+
type: 'string',
|
|
304
|
+
role: 'state',
|
|
305
|
+
read: true,
|
|
306
|
+
write: false,
|
|
307
|
+
states: {
|
|
308
|
+
off: 'Off',
|
|
309
|
+
idle: 'Idle',
|
|
310
|
+
heating: 'Heating',
|
|
311
|
+
cooling: 'Cooling',
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
for (const definition of mappings_1.HEAT_PUMP_SENSOR_DEFINITIONS) {
|
|
315
|
+
await this.ensureDefinitionState(device, definition, dpMap, false);
|
|
316
|
+
}
|
|
317
|
+
for (const definition of mappings_1.HEAT_PUMP_NUMBER_DEFINITIONS) {
|
|
318
|
+
await this.ensureDefinitionState(device, definition, dpMap, true);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async ensureWaterPumpStates(device, dpMap) {
|
|
322
|
+
const deviceBase = this.deviceBase(device);
|
|
323
|
+
const powerDp = dpMap.get(mappings_1.WATER_PUMP_POWER_DP_ID);
|
|
324
|
+
if (powerDp) {
|
|
325
|
+
const stateId = `${deviceBase}.power`;
|
|
326
|
+
await this.ensureState(stateId, {
|
|
327
|
+
name: 'Power',
|
|
328
|
+
type: 'boolean',
|
|
329
|
+
role: 'switch',
|
|
330
|
+
read: true,
|
|
331
|
+
write: powerDp.dpMode === 'rw',
|
|
332
|
+
});
|
|
333
|
+
if (powerDp.dpMode === 'rw') {
|
|
334
|
+
this.writableStates.set(stateId, {
|
|
335
|
+
deviceId: device.id,
|
|
336
|
+
dpId: mappings_1.WATER_PUMP_POWER_DP_ID,
|
|
337
|
+
kind: 'waterPower',
|
|
338
|
+
valueType: 'boolean',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const modeDp = dpMap.get(mappings_1.WATER_PUMP_MODE_DP_ID);
|
|
343
|
+
if (modeDp) {
|
|
344
|
+
const options = (0, dpUtils_1.parseEnumOptions)(modeDp, mappings_1.WATER_PUMP_MODE_FALLBACK, mappings_1.WATER_PUMP_MODE_LABEL_TO_OPTION);
|
|
345
|
+
const stateId = `${deviceBase}.mode`;
|
|
346
|
+
await this.ensureState(stateId, {
|
|
347
|
+
name: 'Mode',
|
|
348
|
+
type: 'string',
|
|
349
|
+
role: 'level.mode',
|
|
350
|
+
read: true,
|
|
351
|
+
write: modeDp.dpMode === 'rw',
|
|
352
|
+
states: (0, dpUtils_1.toStatesObject)(options),
|
|
353
|
+
});
|
|
354
|
+
if (modeDp.dpMode === 'rw') {
|
|
355
|
+
this.writableStates.set(stateId, {
|
|
356
|
+
deviceId: device.id,
|
|
357
|
+
dpId: mappings_1.WATER_PUMP_MODE_DP_ID,
|
|
358
|
+
kind: 'waterPumpMode',
|
|
359
|
+
optionToRaw: (0, dpUtils_1.invertEnum)(options),
|
|
360
|
+
valueType: 'string',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const definition of mappings_1.WATER_PUMP_SENSOR_DEFINITIONS) {
|
|
365
|
+
await this.ensureDefinitionState(device, definition, dpMap, false);
|
|
366
|
+
}
|
|
367
|
+
for (const definition of mappings_1.WATER_PUMP_NUMBER_DEFINITIONS) {
|
|
368
|
+
await this.ensureDefinitionState(device, definition, dpMap, true);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async ensureDefinitionState(device, definition, dpMap, writableDefinition) {
|
|
372
|
+
const dp = dpMap.get(definition.dpId);
|
|
373
|
+
if (!dp || (definition.requireValue && dp.dpValue === null)) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (writableDefinition && dp.dpMode !== 'rw') {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const applied = (0, dpUtils_1.applyDpProperty)(definition, dp);
|
|
380
|
+
const stateId = `${this.deviceBase(device)}.${definition.id}`;
|
|
381
|
+
await this.ensureState(stateId, {
|
|
382
|
+
name: applied.name,
|
|
383
|
+
type: applied.type,
|
|
384
|
+
role: applied.role,
|
|
385
|
+
unit: applied.unit,
|
|
386
|
+
read: applied.read ?? true,
|
|
387
|
+
write: Boolean(writableDefinition && applied.write),
|
|
388
|
+
min: applied.min,
|
|
389
|
+
max: applied.max,
|
|
390
|
+
step: applied.step,
|
|
391
|
+
states: applied.states,
|
|
392
|
+
});
|
|
393
|
+
if (writableDefinition && applied.write) {
|
|
394
|
+
this.writableStates.set(stateId, {
|
|
395
|
+
deviceId: device.id,
|
|
396
|
+
dpId: applied.dpId,
|
|
397
|
+
kind: 'direct',
|
|
398
|
+
scale: applied.scale,
|
|
399
|
+
valueType: applied.type,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async ensureRawStates(device, dpMap) {
|
|
404
|
+
for (const dp of dpMap.values()) {
|
|
405
|
+
const stateId = `${this.deviceBase(device)}.raw.dp_${(0, dpUtils_1.sanitizeObjectId)(dp.dpId)}`;
|
|
406
|
+
const type = (0, dpUtils_1.inferStateType)(dp.dpValue);
|
|
407
|
+
const writable = dp.dpMode === 'rw';
|
|
408
|
+
await this.ensureState(stateId, {
|
|
409
|
+
name: `Raw dp ${dp.dpId}${dp.dpName ? ` (${dp.dpName})` : ''}`,
|
|
410
|
+
type,
|
|
411
|
+
role: 'state',
|
|
412
|
+
read: true,
|
|
413
|
+
write: writable,
|
|
414
|
+
});
|
|
415
|
+
if (writable) {
|
|
416
|
+
this.writableStates.set(stateId, {
|
|
417
|
+
deviceId: device.id,
|
|
418
|
+
dpId: dp.dpId,
|
|
419
|
+
kind: 'raw',
|
|
420
|
+
valueType: type,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async updateDeviceStates(device) {
|
|
426
|
+
const deviceBase = this.deviceBase(device);
|
|
427
|
+
const dpMap = this.dpMap(device);
|
|
428
|
+
await this.setStateAsync(`${deviceBase}.info.name`, { val: device.deviceName ?? device.id, ack: true });
|
|
429
|
+
await this.setStateAsync(`${deviceBase}.info.category`, { val: device.categoryCode ?? '', ack: true });
|
|
430
|
+
await this.setStateAsync(`${deviceBase}.info.version`, { val: device.version ?? '', ack: true });
|
|
431
|
+
if (device.categoryCode === mappings_1.HEAT_PUMP_CATEGORY_CODE) {
|
|
432
|
+
await this.updateHeatPumpStates(device, dpMap);
|
|
433
|
+
}
|
|
434
|
+
else if (device.categoryCode === mappings_1.WATER_PUMP_CATEGORY_CODE) {
|
|
435
|
+
await this.updateWaterPumpStates(device, dpMap);
|
|
436
|
+
}
|
|
437
|
+
if (this.config.createRawStates) {
|
|
438
|
+
await this.updateRawStates(device, dpMap);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async updateHeatPumpStates(device, dpMap) {
|
|
442
|
+
const deviceBase = this.deviceBase(device);
|
|
443
|
+
const powerRaw = this.dpValue(device.id, dpMap, mappings_1.HEAT_PUMP_POWER_DP_ID);
|
|
444
|
+
const isOn = Boolean(powerRaw);
|
|
445
|
+
if (dpMap.has(mappings_1.HEAT_PUMP_POWER_DP_ID)) {
|
|
446
|
+
await this.setStateAsync(`${deviceBase}.power`, { val: isOn, ack: true });
|
|
447
|
+
}
|
|
448
|
+
if (dpMap.has(mappings_1.HEAT_PUMP_HVAC_MODE_DP_ID)) {
|
|
449
|
+
const modeRaw = this.dpValue(device.id, dpMap, mappings_1.HEAT_PUMP_HVAC_MODE_DP_ID);
|
|
450
|
+
const mode = isOn ? (mappings_1.HEAT_HVAC_MODES[Number(modeRaw)] ?? 'off') : 'off';
|
|
451
|
+
await this.setStateAsync(`${deviceBase}.mode`, { val: mode, ack: true });
|
|
452
|
+
}
|
|
453
|
+
const targetDp = dpMap.get(mappings_1.HEAT_PUMP_TARGET_TEMP_DP_ID);
|
|
454
|
+
if (targetDp) {
|
|
455
|
+
const rawValue = this.effectiveDpValue(device.id, targetDp.dpId, targetDp.dpValue);
|
|
456
|
+
await this.setStateAsync(`${deviceBase}.temperature.target`, {
|
|
457
|
+
val: (0, dpUtils_1.scaleRead)(rawValue, (0, dpUtils_1.getDpScale)(targetDp, 0)),
|
|
458
|
+
ack: true,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const presetDp = dpMap.get(mappings_1.HEAT_PUMP_PRESET_DP_ID);
|
|
462
|
+
if (presetDp) {
|
|
463
|
+
const rawValue = this.dpValue(device.id, dpMap, mappings_1.HEAT_PUMP_PRESET_DP_ID);
|
|
464
|
+
const options = this.parseHeatPresetOptions(presetDp);
|
|
465
|
+
await this.setStateAsync(`${deviceBase}.presetMode`, {
|
|
466
|
+
val: options[Number(rawValue)] ?? null,
|
|
467
|
+
ack: true,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
await this.setStateAsync(`${deviceBase}.hvac.action`, {
|
|
471
|
+
val: this.heatPumpAction(device.id, dpMap, isOn),
|
|
472
|
+
ack: true,
|
|
473
|
+
});
|
|
474
|
+
for (const definition of mappings_1.HEAT_PUMP_SENSOR_DEFINITIONS) {
|
|
475
|
+
await this.updateDefinitionState(device, definition, dpMap, false);
|
|
476
|
+
}
|
|
477
|
+
for (const definition of mappings_1.HEAT_PUMP_NUMBER_DEFINITIONS) {
|
|
478
|
+
await this.updateDefinitionState(device, definition, dpMap, true);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async updateWaterPumpStates(device, dpMap) {
|
|
482
|
+
const deviceBase = this.deviceBase(device);
|
|
483
|
+
if (dpMap.has(mappings_1.WATER_PUMP_POWER_DP_ID)) {
|
|
484
|
+
await this.setStateAsync(`${deviceBase}.power`, {
|
|
485
|
+
val: Boolean(this.dpValue(device.id, dpMap, mappings_1.WATER_PUMP_POWER_DP_ID)),
|
|
486
|
+
ack: true,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const modeDp = dpMap.get(mappings_1.WATER_PUMP_MODE_DP_ID);
|
|
490
|
+
if (modeDp) {
|
|
491
|
+
const rawValue = this.dpValue(device.id, dpMap, mappings_1.WATER_PUMP_MODE_DP_ID);
|
|
492
|
+
const options = (0, dpUtils_1.parseEnumOptions)(modeDp, mappings_1.WATER_PUMP_MODE_FALLBACK, mappings_1.WATER_PUMP_MODE_LABEL_TO_OPTION);
|
|
493
|
+
await this.setStateAsync(`${deviceBase}.mode`, {
|
|
494
|
+
val: options[Number(rawValue)] ?? null,
|
|
495
|
+
ack: true,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
for (const definition of mappings_1.WATER_PUMP_SENSOR_DEFINITIONS) {
|
|
499
|
+
await this.updateDefinitionState(device, definition, dpMap, false);
|
|
500
|
+
}
|
|
501
|
+
for (const definition of mappings_1.WATER_PUMP_NUMBER_DEFINITIONS) {
|
|
502
|
+
await this.updateDefinitionState(device, definition, dpMap, true);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async updateDefinitionState(device, definition, dpMap, writableDefinition) {
|
|
506
|
+
const dp = dpMap.get(definition.dpId);
|
|
507
|
+
if (!dp || (definition.requireValue && dp.dpValue === null)) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (writableDefinition && dp.dpMode !== 'rw') {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const applied = (0, dpUtils_1.applyDpProperty)(definition, dp);
|
|
514
|
+
const rawValue = this.effectiveDpValue(device.id, applied.dpId, dp.dpValue);
|
|
515
|
+
const value = (0, dpUtils_1.scaleRead)(rawValue, applied.scale ?? 0);
|
|
516
|
+
await this.setStateAsync(`${this.deviceBase(device)}.${definition.id}`, {
|
|
517
|
+
val: value,
|
|
518
|
+
ack: true,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
async updateRawStates(device, dpMap) {
|
|
522
|
+
for (const dp of dpMap.values()) {
|
|
523
|
+
const rawValue = this.effectiveDpValue(device.id, dp.dpId, dp.dpValue);
|
|
524
|
+
await this.setStateAsync(`${this.deviceBase(device)}.raw.dp_${(0, dpUtils_1.sanitizeObjectId)(dp.dpId)}`, {
|
|
525
|
+
val: (0, dpUtils_1.toStateValue)(rawValue),
|
|
526
|
+
ack: true,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async handleWritableState(localId, mapping, value) {
|
|
531
|
+
if (!this.apiClient) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (mapping.kind === 'heatHvacMode') {
|
|
535
|
+
await this.writeHeatHvacMode(localId, mapping, value);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (mapping.kind === 'heatPresetMode' || mapping.kind === 'waterPumpMode') {
|
|
539
|
+
await this.writeMappedMode(localId, mapping, value);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const coerced = (0, dpUtils_1.coerceStateValue)(value, mapping.valueType);
|
|
543
|
+
const rawValue = mapping.kind === 'heatTargetTemperature' ? (0, dpUtils_1.scaleWrite)(coerced, mapping.scale ?? 0) : coerced;
|
|
544
|
+
await this.apiClient.setDeviceStatus(mapping.deviceId, mapping.dpId, rawValue);
|
|
545
|
+
this.notePendingWrite(mapping.deviceId, mapping.dpId, rawValue);
|
|
546
|
+
await this.setStateAsync(localId, { val: coerced, ack: true });
|
|
547
|
+
this.scheduleWriteRefresh();
|
|
548
|
+
}
|
|
549
|
+
async writeHeatHvacMode(localId, mapping, value) {
|
|
550
|
+
if (!this.apiClient) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const mode = String(value);
|
|
554
|
+
const deviceBase = this.deviceObjectIds.get(mapping.deviceId);
|
|
555
|
+
if (mode === 'off') {
|
|
556
|
+
await this.apiClient.setDeviceStatus(mapping.deviceId, mappings_1.HEAT_PUMP_POWER_DP_ID, false);
|
|
557
|
+
this.notePendingWrite(mapping.deviceId, mappings_1.HEAT_PUMP_POWER_DP_ID, false);
|
|
558
|
+
await this.setStateAsync(localId, { val: 'off', ack: true });
|
|
559
|
+
if (deviceBase) {
|
|
560
|
+
await this.setStateAsync(`${deviceBase}.power`, { val: false, ack: true });
|
|
561
|
+
}
|
|
562
|
+
this.scheduleWriteRefresh();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const rawMode = mapping.optionToRaw?.[mode];
|
|
566
|
+
if (rawMode === undefined) {
|
|
567
|
+
this.log.warn(`Ignoring unknown heat pump mode '${mode}'.`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
await this.apiClient.setDeviceStatus(mapping.deviceId, mappings_1.HEAT_PUMP_POWER_DP_ID, true);
|
|
571
|
+
this.notePendingWrite(mapping.deviceId, mappings_1.HEAT_PUMP_POWER_DP_ID, true);
|
|
572
|
+
await this.apiClient.setDeviceStatus(mapping.deviceId, mapping.dpId, rawMode);
|
|
573
|
+
this.notePendingWrite(mapping.deviceId, mapping.dpId, rawMode);
|
|
574
|
+
if (deviceBase) {
|
|
575
|
+
await this.setStateAsync(`${deviceBase}.power`, { val: true, ack: true });
|
|
576
|
+
}
|
|
577
|
+
await this.setStateAsync(localId, { val: mode, ack: true });
|
|
578
|
+
this.scheduleWriteRefresh();
|
|
579
|
+
}
|
|
580
|
+
async writeMappedMode(localId, mapping, value) {
|
|
581
|
+
if (!this.apiClient) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const option = String(value);
|
|
585
|
+
const rawValue = mapping.optionToRaw?.[option];
|
|
586
|
+
if (rawValue === undefined) {
|
|
587
|
+
this.log.warn(`Ignoring unknown mode option '${option}' for ${localId}.`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
await this.apiClient.setDeviceStatus(mapping.deviceId, mapping.dpId, rawValue);
|
|
591
|
+
this.notePendingWrite(mapping.deviceId, mapping.dpId, rawValue);
|
|
592
|
+
await this.setStateAsync(localId, { val: option, ack: true });
|
|
593
|
+
this.scheduleWriteRefresh();
|
|
594
|
+
}
|
|
595
|
+
heatPumpAction(deviceId, dpMap, isOn) {
|
|
596
|
+
if (!isOn) {
|
|
597
|
+
return 'off';
|
|
598
|
+
}
|
|
599
|
+
const action = this.dpValue(deviceId, dpMap, mappings_1.HEAT_PUMP_ACTION_DP_ID);
|
|
600
|
+
const mode = this.dpValue(deviceId, dpMap, mappings_1.HEAT_PUMP_HVAC_MODE_DP_ID);
|
|
601
|
+
if (Number(action) !== 1) {
|
|
602
|
+
return 'idle';
|
|
603
|
+
}
|
|
604
|
+
if (Number(mode) === 1) {
|
|
605
|
+
return 'heating';
|
|
606
|
+
}
|
|
607
|
+
if (Number(mode) === 2) {
|
|
608
|
+
return 'cooling';
|
|
609
|
+
}
|
|
610
|
+
return 'idle';
|
|
611
|
+
}
|
|
612
|
+
dpValue(deviceId, dpMap, dpId) {
|
|
613
|
+
const dp = dpMap.get(dpId);
|
|
614
|
+
return dp ? this.effectiveDpValue(deviceId, dpId, dp.dpValue) : undefined;
|
|
615
|
+
}
|
|
616
|
+
effectiveDpValue(deviceId, dpId, polledValue) {
|
|
617
|
+
const key = this.pendingKey(deviceId, dpId);
|
|
618
|
+
const pending = this.pendingWrites.get(key);
|
|
619
|
+
if (!pending) {
|
|
620
|
+
return polledValue;
|
|
621
|
+
}
|
|
622
|
+
if ((0, dpUtils_1.valuesMatch)(polledValue, pending.value) || Date.now() >= pending.expiresAt) {
|
|
623
|
+
this.pendingWrites.delete(key);
|
|
624
|
+
return polledValue;
|
|
625
|
+
}
|
|
626
|
+
return pending.value;
|
|
627
|
+
}
|
|
628
|
+
notePendingWrite(deviceId, dpId, value) {
|
|
629
|
+
this.pendingWrites.set(this.pendingKey(deviceId, dpId), {
|
|
630
|
+
value,
|
|
631
|
+
expiresAt: Date.now() + PENDING_WRITE_TIMEOUT_MS,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
scheduleWriteRefresh() {
|
|
635
|
+
if (this.writeRefreshTimer) {
|
|
636
|
+
this.clearTimeout(this.writeRefreshTimer);
|
|
637
|
+
}
|
|
638
|
+
this.writeRefreshTimer = this.setTimeout(() => {
|
|
639
|
+
this.writeRefreshTimer = undefined;
|
|
640
|
+
void this.pollDevices();
|
|
641
|
+
}, WRITE_REFRESH_DELAY_MS);
|
|
642
|
+
}
|
|
643
|
+
selectCourtyard(courtyards, configuredCourtyardId) {
|
|
644
|
+
if (configuredCourtyardId) {
|
|
645
|
+
this.log.info(`Using configured courtyard ID '${configuredCourtyardId}'.`);
|
|
646
|
+
return configuredCourtyardId;
|
|
647
|
+
}
|
|
648
|
+
if (courtyards.length === 0) {
|
|
649
|
+
throw new fairlandApi_1.FairlandApiClientError('No courtyards found for this account.');
|
|
650
|
+
}
|
|
651
|
+
if (courtyards.length > 1) {
|
|
652
|
+
this.log.warn(`Multiple courtyards found. Using '${courtyards[0].name}' (${courtyards[0].id}). Available: ${courtyards
|
|
653
|
+
.map(courtyard => `${courtyard.name}=${courtyard.id}`)
|
|
654
|
+
.join(', ')}`);
|
|
655
|
+
}
|
|
656
|
+
return courtyards[0].id;
|
|
657
|
+
}
|
|
658
|
+
parseHeatPresetOptions(dp) {
|
|
659
|
+
return (0, dpUtils_1.parseEnumOptions)(dp, {}, {});
|
|
660
|
+
}
|
|
661
|
+
getScanIntervalSeconds(config) {
|
|
662
|
+
const parsed = Number(config.scanInterval ?? DEFAULT_SCAN_INTERVAL_SECONDS);
|
|
663
|
+
if (!Number.isFinite(parsed)) {
|
|
664
|
+
return DEFAULT_SCAN_INTERVAL_SECONDS;
|
|
665
|
+
}
|
|
666
|
+
return Math.max(MIN_SCAN_INTERVAL_SECONDS, Math.round(parsed));
|
|
667
|
+
}
|
|
668
|
+
dpMap(device) {
|
|
669
|
+
return new Map((device.dps ?? []).map(dp => [String(dp.dpId), { ...dp, dpId: String(dp.dpId) }]));
|
|
670
|
+
}
|
|
671
|
+
deviceBase(device) {
|
|
672
|
+
let objectId = this.deviceObjectIds.get(device.id);
|
|
673
|
+
if (!objectId) {
|
|
674
|
+
objectId = `devices.${(0, dpUtils_1.sanitizeObjectId)(device.id)}`;
|
|
675
|
+
this.deviceObjectIds.set(device.id, objectId);
|
|
676
|
+
}
|
|
677
|
+
return objectId;
|
|
678
|
+
}
|
|
679
|
+
async ensureDevice(deviceBase, device) {
|
|
680
|
+
if (this.ensuredObjects.has(deviceBase)) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
await this.extendObjectAsync(deviceBase, {
|
|
684
|
+
type: 'device',
|
|
685
|
+
common: {
|
|
686
|
+
name: device.deviceName ?? device.id,
|
|
687
|
+
},
|
|
688
|
+
native: {
|
|
689
|
+
id: device.id,
|
|
690
|
+
categoryCode: device.categoryCode,
|
|
691
|
+
version: device.version,
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
this.ensuredObjects.add(deviceBase);
|
|
695
|
+
}
|
|
696
|
+
async ensureState(stateId, common, native = {}) {
|
|
697
|
+
await this.ensureParentChannels(stateId);
|
|
698
|
+
await this.extendObjectAsync(stateId, {
|
|
699
|
+
type: 'state',
|
|
700
|
+
common,
|
|
701
|
+
native,
|
|
702
|
+
});
|
|
703
|
+
this.ensuredObjects.add(stateId);
|
|
704
|
+
}
|
|
705
|
+
async ensureParentChannels(stateId) {
|
|
706
|
+
const parts = stateId.split('.');
|
|
707
|
+
let current = '';
|
|
708
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
709
|
+
current = current ? `${current}.${parts[index]}` : parts[index];
|
|
710
|
+
if (this.ensuredObjects.has(current)) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (index === 1 && parts[0] === 'devices') {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
await this.extendObjectAsync(current, {
|
|
717
|
+
type: 'channel',
|
|
718
|
+
common: {
|
|
719
|
+
name: this.channelName(parts[index]),
|
|
720
|
+
},
|
|
721
|
+
native: {},
|
|
722
|
+
});
|
|
723
|
+
this.ensuredObjects.add(current);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
channelName(part) {
|
|
727
|
+
return part
|
|
728
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
729
|
+
.replace(/[_-]+/g, ' ')
|
|
730
|
+
.replace(/\b\w/g, match => match.toUpperCase());
|
|
731
|
+
}
|
|
732
|
+
pendingKey(deviceId, dpId) {
|
|
733
|
+
return `${deviceId}:${dpId}`;
|
|
734
|
+
}
|
|
735
|
+
toLocalId(id) {
|
|
736
|
+
const prefix = `${this.namespace}.`;
|
|
737
|
+
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
|
738
|
+
}
|
|
739
|
+
async setConnectionState(connected) {
|
|
740
|
+
await this.setStateAsync('info.connection', { val: connected, ack: true });
|
|
741
|
+
}
|
|
742
|
+
errorMessage(error) {
|
|
743
|
+
if (error instanceof fairlandApi_1.FairlandApiClientAuthenticationError ||
|
|
744
|
+
error instanceof fairlandApi_1.FairlandApiClientCommunicationError ||
|
|
745
|
+
error instanceof fairlandApi_1.FairlandApiClientError ||
|
|
746
|
+
error instanceof Error) {
|
|
747
|
+
return error.message;
|
|
748
|
+
}
|
|
749
|
+
return String(error);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (require.main !== module) {
|
|
753
|
+
module.exports = (options) => new FairlandAdapter(options);
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
new FairlandAdapter();
|
|
757
|
+
}
|