node-red-contrib-homebridge-automation 0.1.12-beta.8 → 0.2.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.
Files changed (41) hide show
  1. package/.github/workflows/Build and Publish.yml +81 -75
  2. package/README.md +7 -4
  3. package/eslint.config.mjs +34 -0
  4. package/package.json +35 -26
  5. package/src/HAP-NodeRed.html +71 -71
  6. package/src/HAP-NodeRed.js +32 -1082
  7. package/src/HapDeviceRoutes.js +59 -0
  8. package/src/hbBaseNode.js +94 -0
  9. package/src/hbConfigNode.js +239 -0
  10. package/src/hbConfigNode.test.js +2179 -0
  11. package/src/hbControlNode.js +77 -0
  12. package/src/hbEventNode.js +23 -0
  13. package/src/hbResumeNode.js +63 -0
  14. package/src/hbStatusNode.js +37 -0
  15. package/test/node-red/.config.nodes.json +453 -0
  16. package/test/node-red/.config.nodes.json.backup +453 -0
  17. package/test/node-red/.config.runtime.json +4 -0
  18. package/test/node-red/.config.runtime.json.backup +3 -0
  19. package/test/node-red/.config.users.json +23 -0
  20. package/test/node-red/.config.users.json.backup +20 -0
  21. package/test/node-red/.flows.json.backup +2452 -0
  22. package/test/node-red/flows.json +2453 -0
  23. package/test/node-red/package.json +6 -0
  24. package/test/node-red/settings.js +593 -0
  25. package/test/node-red/test/node-red/.config.nodes.json +430 -0
  26. package/test/node-red/test/node-red/.config.runtime.json +4 -0
  27. package/test/node-red/test/node-red/.config.runtime.json.backup +3 -0
  28. package/test/node-red/test/node-red/.config.users.json +20 -0
  29. package/test/node-red/test/node-red/.config.users.json.backup +17 -0
  30. package/test/node-red/test/node-red/package.json +6 -0
  31. package/test/node-red/test/node-red/settings.js +593 -0
  32. package/.eslintrc.js +0 -24
  33. package/.github/npm-version-script.js +0 -93
  34. package/.nycrc.json +0 -11
  35. package/src/lib/Accessory.js +0 -126
  36. package/src/lib/Characteristic.js +0 -30
  37. package/src/lib/HbAccessories.js +0 -167
  38. package/src/lib/Homebridge.js +0 -71
  39. package/src/lib/Homebridges.js +0 -68
  40. package/src/lib/Service.js +0 -307
  41. package/src/lib/register.js +0 -156
@@ -0,0 +1,59 @@
1
+ const debug = require('debug')('hapNodeRed:HapDeviceRoutes');
2
+
3
+ class HapDeviceRoutes {
4
+ constructor(RED) {
5
+ this.RED = RED;
6
+ }
7
+
8
+ // POST /hap-device/refresh/:id
9
+ refreshDevice(req, res) {
10
+ const conf = this.RED.nodes.getNode(req.params.id);
11
+ if (conf) {
12
+ res.status(200).send();
13
+ } else {
14
+ debug("Can't refresh until deployed");
15
+ res.status(404).send();
16
+ }
17
+ }
18
+
19
+ // GET /hap-device/evDevices/
20
+ getDevices(req, res, perms) {
21
+ const devices = this.RED.nodes.getNode(req.params.id)?.evDevices;
22
+ if (devices && devices.length) {
23
+ res.send(devices);
24
+ } else {
25
+ res.status(404).send({ error: `No devices found for perms: ${perms}` });
26
+ }
27
+ }
28
+
29
+ // GET /hap-device/evDevices/:id
30
+ getDeviceById(req, res, key) {
31
+ const devices = this.RED.nodes.getNode(req.params.id)?.[key];
32
+ if (devices) {
33
+ // debug(`${key} devices`, devices.length);
34
+ res.send(devices);
35
+ } else {
36
+ res.status(404).send();
37
+ }
38
+ }
39
+
40
+ // Register all routes
41
+ registerRoutes() {
42
+ const routes = [
43
+ { method: 'post', path: '/hap-device/refresh/:id', permission: 'hb-event.read', handler: this.refreshDevice },
44
+ { method: 'get', path: '/hap-device/evDevices/', permission: 'hb-event.read', handler: (req, res) => this.getDevices(req, res, 'ev') },
45
+ { method: 'get', path: '/hap-device/evDevices/:id', permission: 'hb-event.read', handler: (req, res) => this.getDeviceById(req, res, 'evDevices') },
46
+ { method: 'post', path: '/hap-device/refresh/:id', permission: 'hb-resume.read', handler: this.refreshDevice },
47
+ { method: 'get', path: '/hap-device/evDevices/', permission: 'hb-resume.read', handler: (req, res) => this.getDevices(req, res, 'ev') },
48
+ { method: 'get', path: '/hap-device/evDevices/:id', permission: 'hb-resume.read', handler: (req, res) => this.getDeviceById(req, res, 'evDevices') },
49
+ { method: 'get', path: '/hap-device/ctDevices/', permission: 'hb-control.read', handler: (req, res) => this.getDevices(req, res, 'pw') },
50
+ { method: 'get', path: '/hap-device/ctDevices/:id', permission: 'hb-control.read', handler: (req, res) => this.getDeviceById(req, res, 'ctDevices') },
51
+ ];
52
+
53
+ routes.forEach(({ method, path, permission, handler }) => {
54
+ this.RED.httpAdmin[method](path, this.RED.auth.needsPermission(permission), handler.bind(this));
55
+ });
56
+ }
57
+ }
58
+
59
+ module.exports = HapDeviceRoutes;
@@ -0,0 +1,94 @@
1
+ const debug = require('debug')('hapNodeRed:hbBaseNode');
2
+
3
+ class HbBaseNode {
4
+ constructor(config, RED) {
5
+ debug("Constructor:", config.type, JSON.stringify(config));
6
+ RED.nodes.createNode(this, config);
7
+
8
+ if (!config.conf) {
9
+ this.error(`Warning: ${config.type} @ (${config.x}, ${config.y}) not connected to a HB Configuration Node`);
10
+ }
11
+
12
+ this.config = config;
13
+ this.hbConfigNode = RED.nodes.getNode(config.conf);
14
+ this.confId = config.conf;
15
+ this.device = config.device;
16
+ this.service = config.Service;
17
+ this.name = config.name;
18
+ this.fullName = `${config.name} - ${config.Service}`;
19
+ this.hbDevice = null;
20
+
21
+ this.hbConfigNode?.registerClientNode(this);
22
+
23
+ if (this.handleInput) {
24
+ this.on('input', this.handleInput.bind(this));
25
+ }
26
+ if (this.handleHbReady) {
27
+ this.on('hbReady', this.handleHbReady.bind(this))
28
+ }
29
+ this.on('close', this.handleClose.bind(this));
30
+ this.on('hbEvent', this.handleHBEventMessage.bind(this));
31
+ }
32
+
33
+ handleHBEventMessage(service) {
34
+ debug('hbEvent for', this.id, this.type, service.serviceName, JSON.stringify(service.values));
35
+
36
+ this.status({
37
+ text: JSON.stringify(service.values),
38
+ shape: 'dot',
39
+ fill: 'green',
40
+ });
41
+ this.send({ payload: service.values });
42
+ }
43
+
44
+ createMessage(service) {
45
+ return {
46
+ name: this.name,
47
+ payload: service.values,
48
+ Homebridge: service.instance.name,
49
+ Manufacturer: service.accessoryInformation.Manufacturer,
50
+ Service: service.type,
51
+ _device: this.device,
52
+ _confId: this.confId,
53
+ };
54
+ }
55
+
56
+ handleClose(removed, done) {
57
+ debug('close', this.name);
58
+ done();
59
+ }
60
+
61
+ statusText(message) {
62
+ return message.slice(0, 20)
63
+ }
64
+
65
+ /**
66
+ *
67
+ * @param {*} warning - Message to log and display in debug panel
68
+ * @param {*} statusText - Message to display under Node ( If not present, uses warning message text)
69
+ */
70
+ handleWarning(warning, statusText) {
71
+ this.warn(warning);
72
+ this.status({
73
+ text: (statusText ? statusText : warning).slice(0, 20),
74
+ shape: 'ring',
75
+ fill: 'yellow',
76
+ });
77
+ }
78
+
79
+ /**
80
+ *
81
+ * @param {*} warning - Message to log and display in debug panel
82
+ * @param {*} statusText - Message to display under Node ( If not present, uses warning message text)
83
+ */
84
+ handleError(error, statusText) {
85
+ this.error(error);
86
+ this.status({
87
+ text: (statusText ? statusText : error).slice(0, 20),
88
+ shape: 'ring',
89
+ fill: 'red',
90
+ });
91
+ }
92
+ }
93
+
94
+ module.exports = HbBaseNode;
@@ -0,0 +1,239 @@
1
+ const { HapClient } = require('@homebridge/hap-client');
2
+ const debug = require('debug')('hapNodeRed:hbConfigNode');
3
+
4
+ class HBConfigNode {
5
+ constructor(config, RED) {
6
+ if (!config.jest) {
7
+ RED.nodes.createNode(this, config);
8
+
9
+ // Initialize properties
10
+ this.username = config.username;
11
+ this.macAddress = config.macAddress || '';
12
+ this.users = {};
13
+ this.homebridge = null;
14
+ this.evDevices = [];
15
+ this.ctDevices = [];
16
+ this.hbDevices = [];
17
+ this.clientNodes = [];
18
+ // this.log = new Log(console, true);
19
+ this.discoveryTimeout = null;
20
+
21
+ // Initialize HAP client
22
+ this.hapClient = new HapClient({
23
+ config: { debug: false },
24
+ pin: config.username
25
+ });
26
+
27
+ this.hapClient.on('instance-discovered', this.waitForNoMoreDiscoveries);
28
+ this.hapClient.on('discovery-ended', this.hapClient.refreshInstances);
29
+ this.waitForNoMoreDiscoveries();
30
+ this.on('close', this.close.bind(this));
31
+ this.refreshInProcess = true; // Prevents multiple refreshes, hapClient kicks of a discovery on start
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Start device discovery after monitor reports issues
37
+ */
38
+
39
+ refreshDevices = () => {
40
+ if (!this.refreshInProcess) {
41
+
42
+ this.monitor.finish();
43
+ this.debug('Monitor reported homebridge stability issues, refreshing devices');
44
+ this.hapClient.on('instance-discovered', this.waitForNoMoreDiscoveries);
45
+ this.hapClient.resetInstancePool();
46
+ this.waitForNoMoreDiscoveries();
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Wait for no more instance discoveries to be made before publishing services
52
+ */
53
+ waitForNoMoreDiscoveries = () => {
54
+ if (!this.discoveryTimeout) {
55
+ clearTimeout(this.discoveryTimeout);
56
+ this.discoveryTimeout = setTimeout(() => {
57
+ this.debug('No more instances discovered, publishing services');
58
+ this.hapClient.removeListener('instance-discovered', this.waitForNoMoreDiscoveries);
59
+ this.handleReady();
60
+ this.discoveryTimeout = null;
61
+ this.refreshInProcess = false;
62
+ }, 20000); // resetInstancePool() triggers a discovery after 6 seconds. Need to wait for it to finish.
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Populate the list of devices and handle duplicates
68
+ */
69
+ async handleReady() {
70
+ const updatedDevices = await this.hapClient.getAllServices();
71
+ updatedDevices.forEach((updatedService, index) => {
72
+ if (this.hbDevices.find(service => service.uniqueId === updatedService.uniqueId)) {
73
+ const update = this.hbDevices.find(service => service.uniqueId === updatedService.uniqueId);
74
+ update.instance = updatedService.instance;
75
+ } else {
76
+ this.hbDevices.push(updatedService);
77
+ }
78
+ });
79
+ this.evDevices = this.toList({ perms: 'ev' });
80
+ this.ctDevices = this.toList({ perms: 'pw' });
81
+ this.log(`Devices initialized: evDevices: ${this.evDevices.length}, ctDevices: ${this.ctDevices.length}`);
82
+ this.handleDuplicates(this.evDevices);
83
+ this.connectClientNodes();
84
+ }
85
+
86
+ toList(perms) {
87
+ const supportedTypes = new Set([
88
+ 'Battery', 'Carbon Dioxide Sensor', 'Carbon Monoxide Sensor', 'Camera Rtp Stream Management',
89
+ 'Doorbell', 'Fan', 'Fanv2', 'Garage Door Opener', 'Humidity Sensor', 'Input Source',
90
+ 'Leak Sensor', 'Lightbulb', 'Lock Mechanism', 'Motion Sensor', 'Occupancy Sensor',
91
+ 'Outlet', 'Smoke Sensor', 'Speaker', 'Stateless Programmable Switch', 'Switch',
92
+ 'Television', 'Temperature Sensor', 'Thermostat', 'Contact Sensor',
93
+ ]);
94
+ return filterUnique(this.hbDevices)
95
+ .filter(service => supportedTypes.has(service.humanType))
96
+ .map(service => ({
97
+ name: service.serviceName,
98
+ fullName: `${service.serviceName} - ${service.type}`,
99
+ sortName: `${service.serviceName}:${service.type}`,
100
+ uniqueId: `${service.instance.name}${service.instance.username}${service.accessoryInformation.Manufacturer}${service.serviceName}${service.uuid.slice(0, 8)}`,
101
+ homebridge: service.instance.name,
102
+ service: service.type,
103
+ manufacturer: service.accessoryInformation.Manufacturer,
104
+ }))
105
+ .sort((a, b) => a.sortName.localeCompare(b.sortName));
106
+ }
107
+
108
+
109
+ handleDuplicates(list) {
110
+ const seen = new Map();
111
+
112
+ list.forEach(endpoint => {
113
+ const { fullName, uniqueId } = endpoint;
114
+
115
+ if (seen.has(fullName)) {
116
+ this.warn(`Duplicate device name detected: ${fullName}`);
117
+ }
118
+ if (seen.has(uniqueId)) {
119
+ this.error(`Duplicate uniqueId detected: ${uniqueId}`);
120
+ }
121
+
122
+ seen.set(fullName, true);
123
+ seen.set(uniqueId, true);
124
+ });
125
+ }
126
+
127
+ registerClientNode(clientNode) {
128
+ debug('Register: %s type: %s', clientNode.type, clientNode.name);
129
+ this.clientNodes[clientNode.id] = clientNode;
130
+ clientNode.status({ fill: 'yellow', shape: 'ring', text: 'connecting' });
131
+ }
132
+
133
+ async connectClientNodes() {
134
+ debug('connect %s nodes', Object.keys(this.clientNodes).length);
135
+ for (const [key, clientNode] of Object.entries(this.clientNodes)) {
136
+ // debug('_Register: %s type: %s', clientNode.type, clientNode.name, clientNode.instance);
137
+ const matchedDevice = this.hbDevices.find(service =>
138
+ clientNode.device === `${service.instance.name}${service.instance.username}${service.accessoryInformation.Manufacturer}${service.serviceName}${service.uuid.slice(0, 8)}`
139
+ );
140
+
141
+ if (matchedDevice) {
142
+ clientNode.hbDevice = matchedDevice;
143
+ clientNode.status({ fill: 'green', shape: 'dot', text: 'connected' });
144
+ clientNode.emit('hbReady', matchedDevice);
145
+ debug('_Registered: %s type: %s', clientNode.type, matchedDevice.type, matchedDevice.serviceName);
146
+ } else {
147
+ this.error(`ERROR: Device registration failed ${clientNode.name}`);
148
+ }
149
+ };
150
+
151
+ await this.monitorDevices();
152
+ }
153
+
154
+ async monitorDevices() {
155
+ if (Object.keys(this.clientNodes).length) {
156
+ const uniqueDevices = new Set();
157
+
158
+ const monitorNodes = Object.values(this.clientNodes)
159
+ .filter(node => ['hb-status', 'hb-control', 'hb-event', 'hb-resume'].includes(node.type)) // Filter by type
160
+ .filter(node => {
161
+ if (uniqueDevices.has(node.device)) {
162
+ return false; // Exclude duplicates
163
+ }
164
+ uniqueDevices.add(node.device);
165
+ return true; // Include unique devices
166
+ })
167
+ .map(node => node.hbDevice) // Map to hbDevice property
168
+ .filter(Boolean); // Remove any undefined or null values, if present;
169
+ debug('monitorNodes', Object.keys(monitorNodes).length);
170
+ // console.log('monitorNodes', monitorNodes);
171
+ this.monitor = await this.hapClient.monitorCharacteristics(monitorNodes);
172
+ this.monitor.on('service-update', (services) => {
173
+ services.forEach(service => {
174
+ const eventNodes = Object.values(this.clientNodes).filter(clientNode =>
175
+ clientNode.config.device === `${service.instance.name}${service.instance.username}${service.accessoryInformation.Manufacturer}${service.serviceName}${service.uuid.slice(0, 8)}`
176
+ );
177
+ // debug('service-update', service.serviceName, eventNodes);
178
+ eventNodes.forEach(eventNode => eventNode.emit('hbEvent', service));
179
+ });
180
+ });
181
+ this.monitor.on('monitor-close', (instance, hadError) => {
182
+ debug('monitor-close', instance.name, instance.ipAddress, instance.port, hadError)
183
+ this.disconnectClientNodes(instance);
184
+ // this.refreshDevices();
185
+ })
186
+ this.monitor.on('monitor-refresh', (instance, hadError) => {
187
+ debug('monitor-refresh', instance.name, instance.ipAddress, instance.port, hadError)
188
+ this.reconnectClientNodes(instance);
189
+ // this.refreshDevices();
190
+ })
191
+ this.monitor.on('monitor-error', (instance, hadError) => {
192
+ debug('monitor-error', instance, hadError)
193
+ })
194
+ }
195
+ }
196
+
197
+ disconnectClientNodes(instance) {
198
+ debug('disconnectClientNodes', `${instance.ipAddress}:${instance.port}`);
199
+ const clientNodes = Object.values(this.clientNodes).filter(clientNode => {
200
+ return `${clientNode.hbDevice?.instance.ipAddress}:${clientNode.hbDevice?.instance.port}` === `${instance.ipAddress}:${instance.port}`;
201
+ });
202
+
203
+ clientNodes.forEach(clientNode => {
204
+ clientNode.status({ fill: 'red', shape: 'ring', text: 'disconnected' });
205
+ clientNode.emit('hbDisconnected', instance);
206
+ });
207
+ }
208
+
209
+ reconnectClientNodes(instance) {
210
+ debug('reconnectClientNodes', `${instance.ipAddress}:${instance.port}`);
211
+ const clientNodes = Object.values(this.clientNodes).filter(clientNode => {
212
+ return `${clientNode.hbDevice?.instance.ipAddress}:${clientNode.hbDevice?.instance.port}` === `${instance.ipAddress}:${instance.port}`;
213
+ });
214
+
215
+ clientNodes.forEach(clientNode => {
216
+ clientNode.status({ fill: 'green', shape: 'dot', text: 'connected' });
217
+ clientNode.emit('hbReady', clientNode.hbDevice);
218
+ });
219
+ }
220
+
221
+ close() {
222
+ debug('hb-config: close');
223
+ this.hapClient?.destroy();
224
+ }
225
+ }
226
+
227
+
228
+ // Filter unique devices by AID, service name, username, and port
229
+ const filterUnique = (data) => {
230
+ const seen = new Set();
231
+ return data.filter(item => {
232
+ const uniqueKey = `${item.aid}-${item.serviceName}-${item.humanType}-${item.instance.username}-${item.instance.port}`;
233
+ if (seen.has(uniqueKey)) return false;
234
+ seen.add(uniqueKey);
235
+ return true;
236
+ });
237
+ };
238
+
239
+ module.exports = HBConfigNode;