@smarterhomeapp/homebridge-vantage 1.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 +21 -0
- package/README.md +129 -0
- package/config.schema.json +84 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +19 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +269 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +12 -0
- package/dist/platformAccessory.d.ts.map +1 -0
- package/dist/platformAccessory.js +203 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.d.ts +2 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +5 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vantageInfusion.d.ts +49 -0
- package/dist/vantageInfusion.d.ts.map +1 -0
- package/dist/vantageInfusion.js +679 -0
- package/dist/vantageInfusion.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,679 @@
|
|
|
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
|
+
exports.VantageInfusion = void 0;
|
|
37
|
+
const events_1 = require("events");
|
|
38
|
+
const net = __importStar(require("net"));
|
|
39
|
+
const tls = __importStar(require("tls"));
|
|
40
|
+
const xml2js_1 = require("xml2js");
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const TYPE_THERMO = [
|
|
45
|
+
'Thermostat',
|
|
46
|
+
'Vantage.HVAC-Interface_Point_Zone_CHILD',
|
|
47
|
+
'Vantage.VirtualThermostat_PORT',
|
|
48
|
+
'Tekmar.tN4_Gateway_482_Zone_-_Slab_Only_CHILD',
|
|
49
|
+
'Tekmar.tN4_Gateway_482_Zone_CHILD',
|
|
50
|
+
'Legrand.MH_HVAC_Control_CHILD',
|
|
51
|
+
];
|
|
52
|
+
const TYPE_BLIND = [
|
|
53
|
+
'Blind',
|
|
54
|
+
'RelayBlind',
|
|
55
|
+
'QISBlind',
|
|
56
|
+
'Lutron.Shade_x2F_Blind_Child_CHILD',
|
|
57
|
+
'QubeBlind',
|
|
58
|
+
'ESI.RQShadeChannel_CHILD',
|
|
59
|
+
'QMotion.QIS_Channel_CHILD',
|
|
60
|
+
'Somfy.UAI-RS485-Motor_CHILD',
|
|
61
|
+
];
|
|
62
|
+
const OBJECT_TYPES = [
|
|
63
|
+
'Area',
|
|
64
|
+
'Load',
|
|
65
|
+
'Vantage.DDGColorLoad',
|
|
66
|
+
'Legrand.MH_Relay_CHILD',
|
|
67
|
+
'Legrand.MH_Dimmer_CHILD',
|
|
68
|
+
'Jandy.Aqualink_RS_Pump_CHILD',
|
|
69
|
+
'Jandy.Aqualink_RS_Auxiliary_CHILD',
|
|
70
|
+
...TYPE_THERMO,
|
|
71
|
+
...TYPE_BLIND,
|
|
72
|
+
];
|
|
73
|
+
class VantageInfusion extends events_1.EventEmitter {
|
|
74
|
+
getCacheFilePath() {
|
|
75
|
+
// Try /tmp first (Linux/macOS), then fall back to user's home directory
|
|
76
|
+
const tmpPath = '/tmp/vantage.dc';
|
|
77
|
+
if (fs.existsSync('/tmp')) {
|
|
78
|
+
return tmpPath;
|
|
79
|
+
}
|
|
80
|
+
return path.join(os.homedir(), 'vantage.dc');
|
|
81
|
+
}
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
super();
|
|
84
|
+
this.opts = opts;
|
|
85
|
+
this.xml = new xml2js_1.Parser({ explicitArray: false, mergeAttrs: true, trim: true });
|
|
86
|
+
this.isInsecureCmd = true; // 3001 vs 3010
|
|
87
|
+
this.isInsecureCfg = true; // 2001 vs 2010
|
|
88
|
+
}
|
|
89
|
+
/** Probe ports and open command session */
|
|
90
|
+
async start() {
|
|
91
|
+
if (this.opts.forceSSL) {
|
|
92
|
+
this.isInsecureCmd = false;
|
|
93
|
+
this.isInsecureCfg = false;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.isInsecureCmd = await this.portUsable(3001, 'STATUS ALL\n');
|
|
97
|
+
this.isInsecureCfg = await this.portUsable(2001, '<IIntrospection><GetInterfaces><call></call></GetInterfaces></IIntrospection>\n');
|
|
98
|
+
}
|
|
99
|
+
// Legacy-style port logs
|
|
100
|
+
this.opts.log.info(this.isInsecureCmd ? 'Using insecure port: 3001' : 'Using SSL port: 3010');
|
|
101
|
+
this.opts.log.info(this.isInsecureCfg ? 'Using insecure port: 2001' : 'Using SSL port: 2010');
|
|
102
|
+
await this.startCommand();
|
|
103
|
+
}
|
|
104
|
+
portUsable(port, probe) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
let sawData = false;
|
|
107
|
+
const sock = net.connect({ host: this.opts.ipaddress, port }, () => {
|
|
108
|
+
sock.write(probe);
|
|
109
|
+
});
|
|
110
|
+
sock.setTimeout(5000, () => sock.destroy());
|
|
111
|
+
sock.once('connect', () => sock.setTimeout(0));
|
|
112
|
+
sock.on('data', () => {
|
|
113
|
+
sawData = true;
|
|
114
|
+
resolve(true);
|
|
115
|
+
sock.destroy();
|
|
116
|
+
});
|
|
117
|
+
sock.on('close', () => {
|
|
118
|
+
if (!sawData)
|
|
119
|
+
resolve(false);
|
|
120
|
+
});
|
|
121
|
+
sock.on('error', () => {
|
|
122
|
+
/* resolve on close path */
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async startCommand() {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
const onConnect = (socket) => {
|
|
129
|
+
this.command = socket;
|
|
130
|
+
// Legacy “connected” log
|
|
131
|
+
this.opts.log.info(this.isInsecureCmd ? 'connected (command) — Port 3001' : 'connected (command) — Port 3010');
|
|
132
|
+
if (this.opts.username && this.opts.password) {
|
|
133
|
+
this.command.write(`Login ${this.opts.username} ${this.opts.password}\n`);
|
|
134
|
+
}
|
|
135
|
+
this.command.write('STATUS ALL\n');
|
|
136
|
+
this.command.write('ELENABLE 1 AUTOMATION ON\n' +
|
|
137
|
+
'ELENABLE 1 EVENT ON\n' +
|
|
138
|
+
'ELENABLE 1 STATUS ON\n' +
|
|
139
|
+
'ELENABLE 1 STATUSEX ON\n' +
|
|
140
|
+
'ELENABLE 1 SYSTEM ON\n' +
|
|
141
|
+
'ELLOG AUTOMATION ON\n' +
|
|
142
|
+
'ELLOG EVENT ON\n' +
|
|
143
|
+
'ELLOG STATUS ON\n' +
|
|
144
|
+
'ELLOG STATUSEX ON\n' +
|
|
145
|
+
'ELLOG SYSTEM ON\n');
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
if (this.isInsecureCmd) {
|
|
149
|
+
const sock = net.connect({ host: this.opts.ipaddress, port: 3001 }, () => onConnect(sock));
|
|
150
|
+
this.wireCommandSocket(sock);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const sock = tls.connect(3010, this.opts.ipaddress, { rejectUnauthorized: false, requestCert: true }, () => onConnect(sock));
|
|
154
|
+
this.wireCommandSocket(sock);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
wireCommandSocket(sock) {
|
|
159
|
+
sock.on('data', (data) => this.parseRealtime(String(data)));
|
|
160
|
+
sock.on('close', () => {
|
|
161
|
+
// Legacy “Port … has closed!!” lines
|
|
162
|
+
this.opts.log.warn(this.isInsecureCmd ? '\n\nPort 3001 has closed!!\n\n' : '\n\nPort 3010 has closed!!\n\n');
|
|
163
|
+
this.opts.log.warn('Command port closed – reconnecting in 5s');
|
|
164
|
+
setTimeout(() => this.startCommand(), 5000);
|
|
165
|
+
});
|
|
166
|
+
sock.on('end', () => {
|
|
167
|
+
// Legacy “Port … has ended!!”
|
|
168
|
+
this.opts.log.warn(this.isInsecureCmd ? 'Port 3001 has ended!!' : 'Port 3010 has ended!!');
|
|
169
|
+
});
|
|
170
|
+
sock.on('error', (e) => this.opts.log.debug(`Command socket error: ${String(e)}`));
|
|
171
|
+
}
|
|
172
|
+
parseRealtime(buffer) {
|
|
173
|
+
const lines = buffer.split('\n');
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
if (!line)
|
|
176
|
+
continue;
|
|
177
|
+
const dataItem = line.split(' ');
|
|
178
|
+
// this.opts.log.info(line)
|
|
179
|
+
try {
|
|
180
|
+
if (line.startsWith('S:BLIND') ||
|
|
181
|
+
line.startsWith('R:GETBLIND') ||
|
|
182
|
+
(line.startsWith('R:INVOKE') && (dataItem[3] || '').includes('Blind'))) {
|
|
183
|
+
this.emit('blindStatusChange', Number.parseInt(dataItem[1]), Number.parseInt(dataItem[2]));
|
|
184
|
+
}
|
|
185
|
+
if (line.startsWith('S:LOAD ') || line.startsWith('R:GETLOAD ')) {
|
|
186
|
+
this.emit('loadStatusChange', Number.parseInt(dataItem[1]), Number.parseInt(dataItem[2]));
|
|
187
|
+
}
|
|
188
|
+
if (dataItem[0] === 'R:INVOKE' && (dataItem[3] || '').includes('RGBLoad.GetHSL')) {
|
|
189
|
+
this.emit('loadStatusChange', Number.parseInt(dataItem[1]), Number.parseInt(dataItem[2]), Number.parseInt(dataItem[4]));
|
|
190
|
+
}
|
|
191
|
+
if (dataItem[0] === 'S:TEMP') {
|
|
192
|
+
this.emit('thermostatDidChange', Number.parseInt(dataItem[2]));
|
|
193
|
+
}
|
|
194
|
+
else if (dataItem[0] === 'R:INVOKE' && (dataItem[3] || '').includes('Thermostat.GetIndoorTemperature')) {
|
|
195
|
+
this.emit('thermostatIndoorTemperatureChange', Number.parseInt(dataItem[1]), Number.parseFloat(dataItem[2]));
|
|
196
|
+
}
|
|
197
|
+
else if (dataItem[0] === 'S:THERMOP' || dataItem[0] === 'R:GETTHERMOP' || dataItem[0] === 'R:THERMTEMP') {
|
|
198
|
+
let modeVal = 0;
|
|
199
|
+
const token = dataItem[2] || '';
|
|
200
|
+
if (token.includes('OFF'))
|
|
201
|
+
modeVal = 0;
|
|
202
|
+
else if (token.includes('HEAT'))
|
|
203
|
+
modeVal = 1;
|
|
204
|
+
else if (token.includes('COOL'))
|
|
205
|
+
modeVal = 2;
|
|
206
|
+
else
|
|
207
|
+
modeVal = 3;
|
|
208
|
+
if (dataItem[0] === 'S:THERMOP' || dataItem[0] === 'R:GETTHERMOP') {
|
|
209
|
+
this.emit('thermostatIndoorModeChange', Number.parseInt(dataItem[1]), modeVal, -1);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
this.emit('thermostatIndoorModeChange', Number.parseInt(dataItem[1]), modeVal, Number.parseFloat(dataItem[3]));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (line.startsWith('R:INVOKE') && line.indexOf('Object.IsInterfaceSupported') >= 0) {
|
|
216
|
+
this.emit(`isInterfaceSupportedAnswer-${Number.parseInt(dataItem[1])}-${Number.parseInt(dataItem[4])}`, Number.parseInt(dataItem[2]));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
this.opts.log.debug('Realtime parse error for line: ' + line);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
sanitizeXml(xml) {
|
|
225
|
+
// 1. Escape bare ampersands that are not part of an entity
|
|
226
|
+
xml = xml.replace(/&(?!(?:amp|lt|gt|apos|quot);)/g, "&");
|
|
227
|
+
// 2. Example: fix <Area>16</Area> nested inside <Area>...</Area>
|
|
228
|
+
// by renaming the inner tag. Adjust this regex to your schema.
|
|
229
|
+
xml = xml.replace(/<Area>(\d+)<\/Area>/g, "<AreaID>$1</AreaID>");
|
|
230
|
+
return xml;
|
|
231
|
+
}
|
|
232
|
+
/** XML/config discovery; returns devices (replaces old Discover/DiscoverSSL) */
|
|
233
|
+
async discoverDevices() {
|
|
234
|
+
// Check for cached configuration first
|
|
235
|
+
if (this.opts.usecache) {
|
|
236
|
+
const cachePath = this.getCacheFilePath();
|
|
237
|
+
if (fs.existsSync(cachePath)) {
|
|
238
|
+
try {
|
|
239
|
+
const cachedXml = fs.readFileSync(cachePath, 'utf8');
|
|
240
|
+
this.opts.log.info(`Using cached configuration from ${cachePath}`);
|
|
241
|
+
return this.processConfigurationXml(cachedXml);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
this.opts.log.warn(`Failed to read cached configuration: ${error}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const xml = await this.pullConfigurationXml();
|
|
249
|
+
// Legacy: "end configuration download"
|
|
250
|
+
this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
|
|
251
|
+
return this.processConfigurationXml(xml);
|
|
252
|
+
}
|
|
253
|
+
async processConfigurationXml(xml) {
|
|
254
|
+
const sanitized = this.sanitizeXml(xml);
|
|
255
|
+
const parsed = await this.xml.parseStringPromise(sanitized).catch(() => ({}));
|
|
256
|
+
const devices = [];
|
|
257
|
+
const objects = parsed?.Project?.Objects?.Object || [];
|
|
258
|
+
const Areas = objects.filter((el) => Object.keys(el)[0] === 'Area');
|
|
259
|
+
const Area = {};
|
|
260
|
+
for (const a of Areas)
|
|
261
|
+
Area[a.Area.VID] = a.Area;
|
|
262
|
+
const omit = (this.opts.omit || '').replace(/\s+/g, '');
|
|
263
|
+
const omitSet = new Set(omit ? omit.split(',') : []);
|
|
264
|
+
const rangeStr = (this.opts.range || '0,999999999').replace(/\s+/g, '');
|
|
265
|
+
const [minVID, maxVID] = rangeStr.split(',').map((x) => Number.parseInt(x, 10));
|
|
266
|
+
const blindOpenClose = {};
|
|
267
|
+
for (const raw of objects) {
|
|
268
|
+
const key = Object.keys(raw)[0];
|
|
269
|
+
const it = raw[key];
|
|
270
|
+
const vid = String(it.VID);
|
|
271
|
+
if (omitSet.has(vid))
|
|
272
|
+
continue;
|
|
273
|
+
const vidNum = Number.parseInt(vid, 10);
|
|
274
|
+
if (!(vidNum >= minVID && vidNum <= maxVID))
|
|
275
|
+
continue;
|
|
276
|
+
if (!OBJECT_TYPES.includes(it.ObjectType))
|
|
277
|
+
continue;
|
|
278
|
+
// Normalized name (Area + Name, dedup with VID suffix)
|
|
279
|
+
let name = String(it.DName || it.Name || '');
|
|
280
|
+
if (it.Area && Area[it.Area]?.Name)
|
|
281
|
+
name = `${Area[it.Area].Name} ${name}`;
|
|
282
|
+
name = name.replace(/-/g, '') || `VID${vid}`;
|
|
283
|
+
const pushUnique = (dev) => {
|
|
284
|
+
if (devices.find((d) => d.vid === vid))
|
|
285
|
+
return;
|
|
286
|
+
devices.push(dev);
|
|
287
|
+
};
|
|
288
|
+
if (it.DeviceCategory === 'HVAC' || TYPE_THERMO.includes(it.ObjectType)) {
|
|
289
|
+
this.opts.log.info(`New HVAC added (VID=${it.VID}, Name=${it.Name})`);
|
|
290
|
+
pushUnique({
|
|
291
|
+
name,
|
|
292
|
+
address: vid,
|
|
293
|
+
type: 'thermostat',
|
|
294
|
+
vid,
|
|
295
|
+
objectType: it.ObjectType,
|
|
296
|
+
temperature: 0,
|
|
297
|
+
targetTemp: 0,
|
|
298
|
+
heating: 0,
|
|
299
|
+
cooling: 0,
|
|
300
|
+
mode: 0,
|
|
301
|
+
current: 0,
|
|
302
|
+
units: 1,
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (key === 'Load' ||
|
|
307
|
+
it.ObjectType === 'Load' ||
|
|
308
|
+
it.ObjectType === 'Legrand.MH_Dimmer_CHILD' ||
|
|
309
|
+
it.ObjectType === 'Legrand.MH_Relay_CHILD' ||
|
|
310
|
+
it.ObjectType === 'Vantage.DDGColorLoad' ||
|
|
311
|
+
it.ObjectType === 'Jandy.Aqualink_RS_Auxiliary_CHILD' ||
|
|
312
|
+
it.ObjectType === 'Jandy.Aqualink_RS_Pump_CHILD') {
|
|
313
|
+
const isRelay = it.ObjectType === 'Jandy.Aqualink_RS_Pump_CHILD' ||
|
|
314
|
+
it.ObjectType === 'Jandy.Aqualink_RS_Auxiliary_CHILD' ||
|
|
315
|
+
it.ObjectType === 'Legrand.MH_Relay_CHILD' ||
|
|
316
|
+
it.LoadType === 'Fluor. Mag non-Dim' ||
|
|
317
|
+
it.LoadType === 'LED non-Dim' ||
|
|
318
|
+
it.LoadType === 'Fluor. Electronic non-Dim' ||
|
|
319
|
+
it.LoadType === 'Low Voltage Relay' ||
|
|
320
|
+
it.LoadType === 'Motor' ||
|
|
321
|
+
it.LoadType === 'High Voltage Relay';
|
|
322
|
+
if (isRelay) {
|
|
323
|
+
this.opts.log.info(`New relay added (VID=${it.VID}, Name=${it.Name}, RELAY)`);
|
|
324
|
+
pushUnique({
|
|
325
|
+
name,
|
|
326
|
+
address: vid,
|
|
327
|
+
type: 'relay',
|
|
328
|
+
vid,
|
|
329
|
+
objectType: it.ObjectType,
|
|
330
|
+
loadType: it.LoadType,
|
|
331
|
+
bri: 100,
|
|
332
|
+
power: false,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
else if (it.ObjectType === 'Vantage.DDGColorLoad') {
|
|
336
|
+
this.opts.log.info(`New load added (VID=${it.VID}, Name=${it.Name}, RGB)`);
|
|
337
|
+
pushUnique({
|
|
338
|
+
name,
|
|
339
|
+
address: vid,
|
|
340
|
+
type: 'rgb',
|
|
341
|
+
vid,
|
|
342
|
+
objectType: it.ObjectType,
|
|
343
|
+
loadType: it.LoadType,
|
|
344
|
+
bri: 100,
|
|
345
|
+
power: false,
|
|
346
|
+
sat: 0,
|
|
347
|
+
hue: 0,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
this.opts.log.info(`New load added (VID=${it.VID}, Name=${it.Name}, DIMMER)`);
|
|
352
|
+
pushUnique({
|
|
353
|
+
name,
|
|
354
|
+
address: vid,
|
|
355
|
+
type: 'dimmer',
|
|
356
|
+
vid,
|
|
357
|
+
objectType: it.ObjectType,
|
|
358
|
+
loadType: it.LoadType,
|
|
359
|
+
bri: 100,
|
|
360
|
+
power: false,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (TYPE_BLIND.includes(it.ObjectType)) {
|
|
366
|
+
if (it.ObjectType === 'RelayBlind') {
|
|
367
|
+
blindOpenClose[it.OpenLoad] = it.OpenLoad;
|
|
368
|
+
blindOpenClose[it.CloseLoad] = it.CloseLoad;
|
|
369
|
+
}
|
|
370
|
+
this.opts.log.info(`New Blind added (VID=${it.VID}, Name=${it.Name}, BLIND)`);
|
|
371
|
+
pushUnique({ name, address: vid, type: 'blind', vid, objectType: it.ObjectType, pos: 100, posState: 2 });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Drop synthetic relay children of RelayBlind
|
|
376
|
+
const filtered = devices.filter((d) => !blindOpenClose[d.address]);
|
|
377
|
+
// Legacy “Found %f devices”
|
|
378
|
+
this.opts.log.info(`Found ${filtered.length} devices`);
|
|
379
|
+
if (filtered.length >= 150) {
|
|
380
|
+
// Legacy Apple limit line
|
|
381
|
+
this.opts.log.warn('Number of devices exceeds Apples limit of 149. Only loading first 149 devices. Please omit some loads');
|
|
382
|
+
filtered.splice(149);
|
|
383
|
+
}
|
|
384
|
+
return filtered;
|
|
385
|
+
}
|
|
386
|
+
pullConfigurationXml() {
|
|
387
|
+
return new Promise((resolve) => {
|
|
388
|
+
const finishOnce = (socket, xml) => {
|
|
389
|
+
if (finishOnce._done)
|
|
390
|
+
return;
|
|
391
|
+
finishOnce._done = true;
|
|
392
|
+
// Save configuration to cache if usecache is enabled
|
|
393
|
+
if (this.opts.usecache) {
|
|
394
|
+
try {
|
|
395
|
+
const cachePath = this.getCacheFilePath();
|
|
396
|
+
fs.writeFileSync(cachePath, xml);
|
|
397
|
+
this.opts.log.info(`Configuration cached to ${cachePath}`);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
this.opts.log.warn(`Failed to cache configuration: ${error}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// legacy log + event
|
|
404
|
+
this.opts.log.debug('VantagePlatform for InFusion Controller (end configuration download)');
|
|
405
|
+
this.emit('endDownloadConfiguration', xml);
|
|
406
|
+
try {
|
|
407
|
+
socket.destroy();
|
|
408
|
+
}
|
|
409
|
+
catch { /* noop */ }
|
|
410
|
+
resolve(xml);
|
|
411
|
+
};
|
|
412
|
+
const onData = (socket) => {
|
|
413
|
+
this.opts.log.info('load dc file'); // legacy
|
|
414
|
+
let buffer = '';
|
|
415
|
+
let controller = 1;
|
|
416
|
+
let shouldBreak = false;
|
|
417
|
+
const readObjects = [];
|
|
418
|
+
const objectDict = {};
|
|
419
|
+
let writeCount = 0;
|
|
420
|
+
// progress watchdog: if we stop seeing new parsable states, finish with what we have
|
|
421
|
+
let lastProgress = Date.now();
|
|
422
|
+
const progress = () => { lastProgress = Date.now(); };
|
|
423
|
+
const watchdog = setInterval(() => {
|
|
424
|
+
if (finishOnce._done) {
|
|
425
|
+
clearInterval(watchdog);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (readObjects.length && Date.now() - lastProgress > 2000) {
|
|
429
|
+
// Build minimal XML with what we gathered
|
|
430
|
+
const xml = '<Project><Objects>' +
|
|
431
|
+
readObjects.map((o) => {
|
|
432
|
+
const k = Object.keys(o)[0];
|
|
433
|
+
const v = o[k];
|
|
434
|
+
const body = Object.keys(v).map((kk) => `<${kk}>${String(v[kk])}</${kk}>`).join('');
|
|
435
|
+
return `<Object><${k}>${body}</${k}></Object>`;
|
|
436
|
+
}).join('') +
|
|
437
|
+
'</Objects></Project>';
|
|
438
|
+
clearInterval(watchdog);
|
|
439
|
+
finishOnce(socket, xml);
|
|
440
|
+
}
|
|
441
|
+
}, 750);
|
|
442
|
+
const writeOpenFilter = () => {
|
|
443
|
+
const type = OBJECT_TYPES[writeCount];
|
|
444
|
+
this.opts.log.info(`parse Json: ${type} on controller: ${controller.toString()}`); // legacy text, visible
|
|
445
|
+
socket.write(`<?Master ${controller}?>` +
|
|
446
|
+
`<IConfiguration><OpenFilter><call><Objects><ObjectType>${type}</ObjectType></Objects></call></OpenFilter></IConfiguration>\n`);
|
|
447
|
+
progress();
|
|
448
|
+
};
|
|
449
|
+
// Kickoff (+ fallback if login never parses)
|
|
450
|
+
let loginFallback = null;
|
|
451
|
+
const clearLoginFallback = () => { if (loginFallback) {
|
|
452
|
+
clearTimeout(loginFallback);
|
|
453
|
+
loginFallback = null;
|
|
454
|
+
} };
|
|
455
|
+
if (this.opts.username && this.opts.password) {
|
|
456
|
+
this.opts.log.info('Kickoff: sending <ILogin>');
|
|
457
|
+
socket.write(`<ILogin><Login><call><User>${this.opts.username}</User><Password>${this.opts.password}</Password></call></Login></ILogin>\n`);
|
|
458
|
+
progress();
|
|
459
|
+
loginFallback = setTimeout(() => {
|
|
460
|
+
this.opts.log.warn('Login response timeout — calling writeOpenFilter() anyway');
|
|
461
|
+
writeOpenFilter();
|
|
462
|
+
}, 2500);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
this.opts.log.info('Kickoff: writeOpenFilter() (no login)');
|
|
466
|
+
writeOpenFilter();
|
|
467
|
+
}
|
|
468
|
+
socket.on('data', (chunk) => {
|
|
469
|
+
buffer += String(chunk).replace('\ufeff', '');
|
|
470
|
+
this.xml.parseString(buffer, (err, parsedAny) => {
|
|
471
|
+
if (err)
|
|
472
|
+
return; // wait for more chunks
|
|
473
|
+
const parsed = parsedAny?.smarterHome ?? parsedAny;
|
|
474
|
+
// ----- LOGIN -----
|
|
475
|
+
if (parsed?.ILogin?.Login) {
|
|
476
|
+
const ok = String(parsed.ILogin.Login.return) === 'true';
|
|
477
|
+
if (ok)
|
|
478
|
+
this.opts.log.info('Login successful');
|
|
479
|
+
else
|
|
480
|
+
this.opts.log.warn('Login failed trying to get data anyways');
|
|
481
|
+
buffer = '';
|
|
482
|
+
clearLoginFallback();
|
|
483
|
+
writeCount = 0;
|
|
484
|
+
writeOpenFilter();
|
|
485
|
+
// return;
|
|
486
|
+
}
|
|
487
|
+
// ----- OPEN FILTER (handle) -----
|
|
488
|
+
if (parsed?.IConfiguration?.OpenFilter?.return) {
|
|
489
|
+
if (!buffer.includes(`<?Master ${controller}?>`) && buffer.includes(`<?Master`)) {
|
|
490
|
+
if (controller === 1) {
|
|
491
|
+
try {
|
|
492
|
+
const tmpStr = buffer.slice(9);
|
|
493
|
+
const res = tmpStr.split('?');
|
|
494
|
+
controller = parseInt(res[0]);
|
|
495
|
+
}
|
|
496
|
+
catch { /* ignore */ }
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
this.opts.log.info("breaking");
|
|
500
|
+
shouldBreak = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const hFilter = parsed.IConfiguration.OpenFilter.return;
|
|
504
|
+
if (!objectDict[hFilter] && !shouldBreak) {
|
|
505
|
+
buffer = '';
|
|
506
|
+
objectDict[hFilter] = hFilter;
|
|
507
|
+
writeCount++;
|
|
508
|
+
socket.write(`<IConfiguration><GetFilterResults><call><Count>1000</Count><WholeObject>true</WholeObject><hFilter>${hFilter}</hFilter></call></GetFilterResults></IConfiguration>\n`);
|
|
509
|
+
progress();
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// ----- GET FILTER RESULTS -----
|
|
514
|
+
if (parsed?.IConfiguration?.GetFilterResults?.return?.Object) {
|
|
515
|
+
const elements = parsed.IConfiguration.GetFilterResults.return.Object;
|
|
516
|
+
const list = Array.isArray(elements) ? elements : [elements];
|
|
517
|
+
for (const el of list) {
|
|
518
|
+
const type = OBJECT_TYPES[writeCount - 1];
|
|
519
|
+
const item = el[type] ?? el; // tolerate firmwares that return without type wrapper
|
|
520
|
+
if (item)
|
|
521
|
+
item.ObjectType = type;
|
|
522
|
+
const elemDict = {};
|
|
523
|
+
elemDict[type] = item;
|
|
524
|
+
readObjects.push(elemDict);
|
|
525
|
+
}
|
|
526
|
+
buffer = '';
|
|
527
|
+
if (writeCount >= OBJECT_TYPES.length) {
|
|
528
|
+
controller++;
|
|
529
|
+
writeCount = 0;
|
|
530
|
+
}
|
|
531
|
+
// writeOpenFilter()
|
|
532
|
+
// return;
|
|
533
|
+
}
|
|
534
|
+
if (parsed?.IConfiguration?.GetFilterResults) {
|
|
535
|
+
buffer = '';
|
|
536
|
+
if (writeCount >= OBJECT_TYPES.length) {
|
|
537
|
+
controller++;
|
|
538
|
+
writeCount = 0;
|
|
539
|
+
}
|
|
540
|
+
writeOpenFilter();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// ----- END CONDITION -----
|
|
544
|
+
if (shouldBreak) {
|
|
545
|
+
const xml = '<Project><Objects>' +
|
|
546
|
+
readObjects.map((o) => {
|
|
547
|
+
const k = Object.keys(o)[0];
|
|
548
|
+
const v = o[k];
|
|
549
|
+
const body = Object.keys(v).map((kk) => `<${kk}>${String(v[kk])}</${kk}>`).join('');
|
|
550
|
+
return `<Object><${k}>${body}</${k}></Object>`;
|
|
551
|
+
}).join('') +
|
|
552
|
+
'</Objects></Project>';
|
|
553
|
+
clearLoginFallback();
|
|
554
|
+
clearInterval(watchdog);
|
|
555
|
+
finishOnce(socket, xml);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
socket.on('end', () => {
|
|
560
|
+
this.opts.log.info("ending" + shouldBreak);
|
|
561
|
+
if (!finishOnce._done && readObjects.length) {
|
|
562
|
+
const xml = '<Project><Objects>' +
|
|
563
|
+
readObjects.map((o) => {
|
|
564
|
+
const k = Object.keys(o)[0];
|
|
565
|
+
const v = o[k];
|
|
566
|
+
const body = Object.keys(v).map((kk) => `<${kk}>${String(v[kk])}</${kk}>`).join('');
|
|
567
|
+
return `<Object><${k}>${body}</${k}></Object>`;
|
|
568
|
+
}).join('') +
|
|
569
|
+
'</Objects></Project>';
|
|
570
|
+
clearLoginFallback();
|
|
571
|
+
clearInterval(watchdog);
|
|
572
|
+
finishOnce(socket, xml);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
socket.on('close', () => {
|
|
576
|
+
this.opts.log.info("closing" + shouldBreak);
|
|
577
|
+
if (!finishOnce._done && readObjects.length) {
|
|
578
|
+
const xml = '<Project><Objects>' +
|
|
579
|
+
readObjects.map((o) => {
|
|
580
|
+
const k = Object.keys(o)[0];
|
|
581
|
+
const v = o[k];
|
|
582
|
+
const body = Object.keys(v).map((kk) => `<${kk}>${String(v[kk])}</${kk}>`).join('');
|
|
583
|
+
return `<Object><${k}>${body}</${k}></Object>`;
|
|
584
|
+
}).join('') +
|
|
585
|
+
'</Objects></Project>';
|
|
586
|
+
clearLoginFallback();
|
|
587
|
+
clearInterval(watchdog);
|
|
588
|
+
finishOnce(socket, xml);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
};
|
|
592
|
+
if (this.isInsecureCfg) {
|
|
593
|
+
const s = net.connect({ host: this.opts.ipaddress, port: 2001 }, () => onData(s));
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
const s = tls.connect(2010, this.opts.ipaddress, { rejectUnauthorized: false, requestCert: true }, () => onData(s));
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
// ===== Commands used by platformAccessory handlers =====
|
|
601
|
+
async setRelayOrDim(vid, on, bri, type) {
|
|
602
|
+
if (type === 'relay')
|
|
603
|
+
return this.setRelay(vid, on);
|
|
604
|
+
const level = on ? Math.max(1, Number(bri)) : 0;
|
|
605
|
+
// Ramp (duration=1) to desired level (matches legacy Load.Ramp usage)
|
|
606
|
+
this.command.write(`INVOKE ${vid} Load.Ramp 6 1 ${level}\n`);
|
|
607
|
+
}
|
|
608
|
+
async setBrightness(vid, level) {
|
|
609
|
+
const lvl = Math.max(0, Math.min(100, Number(level)));
|
|
610
|
+
this.command.write(`INVOKE ${vid} Load.Ramp 6 1 ${lvl}\n`);
|
|
611
|
+
}
|
|
612
|
+
async setHue(vid, hue) {
|
|
613
|
+
// Note: Full HSL write typically needs current S/L. The platform also updates Saturation/Brightness shortly after.
|
|
614
|
+
this.command.write(`INVOKE ${vid} RGBLoad.SetHSL ${Math.round(hue)} 100 100\n`);
|
|
615
|
+
}
|
|
616
|
+
async setSaturation(vid, sat) {
|
|
617
|
+
// Similarly, send a best-effort write; brightness updates are handled by setBrightness
|
|
618
|
+
this.command.write(`INVOKE ${vid} RGBLoad.SetHSL 0 ${Math.round(sat)} 100\n`);
|
|
619
|
+
}
|
|
620
|
+
async setRelay(vid, on) {
|
|
621
|
+
const level = on ? 100 : 0;
|
|
622
|
+
this.command.write(`LOAD ${vid} ${level}\n`);
|
|
623
|
+
}
|
|
624
|
+
async setBlindPosition(vid, pos) {
|
|
625
|
+
this.command.write(`BLIND ${vid} POS ${Math.max(0, Math.min(100, Math.round(pos)))}\n`);
|
|
626
|
+
}
|
|
627
|
+
refreshThermostat(vid) {
|
|
628
|
+
this.command.write(`INVOKE ${vid} Thermostat.GetIndoorTemperature\n`);
|
|
629
|
+
this.command.write(`GETTHERMOP ${vid}\n`);
|
|
630
|
+
this.command.write(`GETTHERMTEMP ${vid} HEAT\n`);
|
|
631
|
+
this.command.write(`GETTHERMTEMP ${vid} COOL\n`);
|
|
632
|
+
this.command.write(`GETTHERMOP ${vid}\n`);
|
|
633
|
+
}
|
|
634
|
+
async setThermostatMode(vid, mode) {
|
|
635
|
+
const out = mode === 0 ? 'OFF' : mode === 1 ? 'HEAT' : mode === 2 ? 'COOL' : 'AUTO';
|
|
636
|
+
this.command.write(`THERMOP ${vid} ${out}\n`);
|
|
637
|
+
}
|
|
638
|
+
async setThermostatTarget(vid, value, mode, heating, cooling) {
|
|
639
|
+
// Match original logic: set temperature based on mode
|
|
640
|
+
const v = Math.round(value);
|
|
641
|
+
if (mode === 1) {
|
|
642
|
+
this.command.write(`THERMTEMP ${vid} HEAT ${v}\n`);
|
|
643
|
+
}
|
|
644
|
+
else if (mode === 2) {
|
|
645
|
+
this.command.write(`THERMTEMP ${vid} COOL ${v}\n`);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// If no mode specified, set both (legacy behavior)
|
|
649
|
+
this.command.write(`THERMTEMP ${vid} HEAT ${v}\n`);
|
|
650
|
+
this.command.write(`THERMTEMP ${vid} COOL ${v}\n`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Thermostat getter methods (from original code)
|
|
654
|
+
Thermostat_GetIndoorTemperature(vid) {
|
|
655
|
+
this.opts.log.debug(`Thermostat_GetIndoorTemperature ${vid}`);
|
|
656
|
+
this.command.write(`INVOKE ${vid} Thermostat.GetIndoorTemperature\n`);
|
|
657
|
+
}
|
|
658
|
+
Thermostat_GetState(vid) {
|
|
659
|
+
this.opts.log.debug(`Thermostat_GetState ${vid}`);
|
|
660
|
+
this.command.write(`GETTHERMOP ${vid}\n`);
|
|
661
|
+
}
|
|
662
|
+
Thermostat_GetHeating(vid) {
|
|
663
|
+
this.opts.log.debug(`Thermostat_GetHeating ${vid}`);
|
|
664
|
+
this.command.write(`GETTHERMTEMP ${vid} HEAT\n`);
|
|
665
|
+
}
|
|
666
|
+
Thermostat_GetCooling(vid) {
|
|
667
|
+
this.opts.log.debug(`Thermostat_GetCooling ${vid}`);
|
|
668
|
+
this.command.write(`GETTHERMTEMP ${vid} COOL\n`);
|
|
669
|
+
}
|
|
670
|
+
// OPTIONAL: legacy name aliases (harmless no-ops to help grep/muscle memory)
|
|
671
|
+
Discover() {
|
|
672
|
+
return this.discoverDevices();
|
|
673
|
+
}
|
|
674
|
+
DiscoverSSL() {
|
|
675
|
+
this.isInsecureCfg = false;
|
|
676
|
+
return this.discoverDevices();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
exports.VantageInfusion = VantageInfusion;
|