homebridge-shelly-plus-rgbw-pm 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/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # homebridge-shelly-plus-rgbw-pm
2
+
3
+ Homebridge platform plugin for **Shelly Plus RGBW PM**.
4
+
5
+ It automatically detects the device profile and exposes accessories like this:
6
+
7
+ - `light` profile: up to 4 dimmer accessories (`Light.Set`, `Light.GetStatus`)
8
+ - `rgb` profile: 1 color light accessory (`RGB.Set`, `RGB.GetStatus`)
9
+ - `rgbw` profile: 1 color light accessory (`RGBW.Set`, `RGBW.GetStatus`)
10
+
11
+ Profile detection is based on Shelly Gen2 RPC status/device info:
12
+
13
+ - `Shelly.GetStatus`
14
+ - `Shelly.GetDeviceInfo`
15
+
16
+ Device API reference used: [Shelly Plus RGBW PM docs](https://shelly-api-docs.shelly.cloud/gen2/Devices/Gen2/ShellyPlusRGBWPM)
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install -g homebridge-shelly-plus-rgbw-pm
22
+ ```
23
+
24
+ ## Homebridge UI setup
25
+
26
+ In Homebridge Config UI X, add platform **Shelly Plus RGBW PM** and set:
27
+
28
+ - **Display Name** (required)
29
+ - **Shelly Devices** (required): add one entry per Shelly Plus RGBW PM device
30
+
31
+ Per device options:
32
+
33
+ - **Device Name**: name you want to see for this device in Homebridge.
34
+ - **IP Address or mDNS Name**: the mDNS name is persistent even in a DHCP-based IP environment. You can find the device ID at `http://<device-ip-address>/shelly` (field `id`). Append `.local` to that ID to obtain the mDNS hostname, like shellyplusrgbwpm-xxxxxxxxxxxx.local.
35
+ - **Show Dimmer O1** / **O2** / **O3** / **O4**: for Shelly Plus RGBW PM devices in light mode, select the dimmers you want to see as devices in Homebridge. If the device is in RGBW or RGB mode, these checkboxes are ignored.
36
+
37
+ ## Example config.json
38
+
39
+ ```json
40
+ {
41
+ "platforms": [
42
+ {
43
+ "platform": "ShellyPlusRGBWPM",
44
+ "name": "Shelly Plus RGBW PM",
45
+ "devices": [
46
+ {
47
+ "name": "Kitchen Lights",
48
+ "host": "shellyplusrgbwpm-xxxxxxxxxxxx.local",
49
+ "showDimmer1": true,
50
+ "showDimmer2": true,
51
+ "showDimmer3": false,
52
+ "showDimmer4": false
53
+ },
54
+ {
55
+ "name": "Patio Lights",
56
+ "host": "shellyplusrgbwpm-yyyyyyyyyyyy.local",
57
+ "showDimmer1": true,
58
+ "showDimmer2": true,
59
+ "showDimmer3": true,
60
+ "showDimmer4": true
61
+ }
62
+ ]
63
+ }
64
+ ]
65
+ }
66
+ ```
67
+
68
+ ## Notes
69
+
70
+ - The plugin polls the device every 5 seconds.
71
+ - If the Shelly profile changes (for example `light` to `rgbw`), the plugin rebuilds accessories automatically.
72
+ - RGBW white output is mapped to HomeKit by using low saturation (`Saturation = 0`) as white mode.
@@ -0,0 +1,68 @@
1
+ {
2
+ "pluginAlias": "ShellyPlusRGBWPM",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "headerDisplay": "This plugin maps Shelly Plus RGBW PM devices to Homebridge accessories, supporting RGBW/RGB light mode or up to four independent dimmer channels.",
6
+ "footerDisplay": "For help or in case of issues please visit the [GitHub repository](https://github.com/Krillle/homebridge-shelly-plus-rgbw-pm/issues).",
7
+ "schema": {
8
+ "type": "object",
9
+ "required": [
10
+ "name",
11
+ "devices"
12
+ ],
13
+ "properties": {
14
+ "name": {
15
+ "title": "Display Name",
16
+ "type": "string",
17
+ "default": "Shelly Plus RGBW PM"
18
+ },
19
+ "devices": {
20
+ "title": "Shelly Devices",
21
+ "type": "array",
22
+ "description": "Add one or more Shelly Plus RGBW PM devices.",
23
+ "items": {
24
+ "title": "Shelly Device",
25
+ "type": "object",
26
+ "required": [
27
+ "host"
28
+ ],
29
+ "properties": {
30
+ "name": {
31
+ "title": "Device Name",
32
+ "type": "string",
33
+ "default": "Shelly Plus RGBW PM",
34
+ "description": "Name you want to see for this device in Homebridge."
35
+ },
36
+ "host": {
37
+ "title": "IP Address or mDNS Name",
38
+ "type": "string",
39
+ "placeholder": "192.168.1.60 or shellyplusrgbwpm-xxxxxxxxxxxx.local",
40
+ "description": "The mDNS name is persistent, even in a DHCP-based IP environment. You can find the device&rsquo;s ID at http://&lt;device-ip-address&gt;/shelly (field &quot;id&quot;). Append .local to this ID to obtain the mDNS hostname."
41
+ },
42
+ "showDimmer1": {
43
+ "title": "Show Dimmer O1",
44
+ "type": "boolean",
45
+ "default": true
46
+ },
47
+ "showDimmer2": {
48
+ "title": "Show Dimmer O2",
49
+ "type": "boolean",
50
+ "default": true
51
+ },
52
+ "showDimmer3": {
53
+ "title": "Show Dimmer O3",
54
+ "type": "boolean",
55
+ "default": true
56
+ },
57
+ "showDimmer4": {
58
+ "title": "Show Dimmer O4",
59
+ "type": "boolean",
60
+ "default": true,
61
+ "description": "For Shelly Plus RGBW PM devices in light mode, select the dimmers you want to see as devices in Homebridge. If the device is in RGBW or RGB mode, these checkboxes are ignored."
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
package/index.js ADDED
@@ -0,0 +1,1064 @@
1
+ 'use strict';
2
+
3
+ const PLUGIN_NAME = 'homebridge-shelly-plus-rgbw-pm';
4
+ const PLATFORM_NAME = 'ShellyPlusRGBWPM';
5
+
6
+ module.exports = (api) => {
7
+ api.registerPlatform(PLATFORM_NAME, ShellyPlusRGBWPMPlatform);
8
+ };
9
+
10
+ class ShellyPlusRGBWPMPlatform {
11
+ constructor(log, config, api) {
12
+ this.log = log;
13
+ this.config = config || {};
14
+ this.api = api;
15
+
16
+ this.Service = this.api.hap.Service;
17
+ this.Characteristic = this.api.hap.Characteristic;
18
+
19
+ this.accessories = new Map();
20
+ this.commandQueues = new Map();
21
+
22
+ this.pollTimer = null;
23
+ this.pollInFlight = null;
24
+
25
+ this.platformName = normalizeName(this.config.name) || 'Shelly Plus RGBW PM';
26
+ this.devices = this.parseConfiguredDevices();
27
+
28
+ if (!this.devices.size) {
29
+ this.log.error('No Shelly devices configured. Add one or more entries under "devices".');
30
+ return;
31
+ }
32
+
33
+ this.api.on('didFinishLaunching', async () => {
34
+ await this.initialize();
35
+ });
36
+ }
37
+
38
+ configureAccessory(accessory) {
39
+ accessory.context = accessory.context || {};
40
+ this.accessories.set(accessory.UUID, accessory);
41
+ }
42
+
43
+ parseConfiguredDevices() {
44
+ const devices = new Map();
45
+ const configuredDevices = Array.isArray(this.config.devices)
46
+ ? this.config.devices
47
+ : [];
48
+
49
+ configuredDevices.forEach((deviceConfig, index) => {
50
+ this.addConfiguredDevice(devices, deviceConfig, {
51
+ fallbackName: `${this.platformName} ${index + 1}`,
52
+ label: `devices[${index}]`,
53
+ });
54
+ });
55
+
56
+ return devices;
57
+ }
58
+
59
+ addConfiguredDevice(devices, config, options) {
60
+ const host = sanitizeHost(config ? config.host : '');
61
+
62
+ if (!host) {
63
+ if (options && options.label) {
64
+ this.log.warn('Ignoring %s because host is missing.', options.label);
65
+ }
66
+
67
+ return;
68
+ }
69
+
70
+ if (devices.has(host)) {
71
+ this.log.warn('Duplicate Shelly host %s found in config. Ignoring duplicate entry.', host);
72
+ return;
73
+ }
74
+
75
+ const displayName = normalizeName(config.name) || options.fallbackName || host;
76
+ const showDimmers = [0, 1, 2, 3].map((channel) => this.isDimmerEnabled(config, channel));
77
+
78
+ devices.set(host, {
79
+ host,
80
+ displayName,
81
+ showDimmers,
82
+ client: new ShellyRpcClient(host),
83
+ profile: null,
84
+ deviceInfo: {},
85
+ descriptors: [],
86
+ discovered: false,
87
+ });
88
+ }
89
+
90
+ async initialize() {
91
+ try {
92
+ await this.refreshTopology();
93
+ } catch (error) {
94
+ this.log.error('Initial Shelly discovery failed: %s', error.message);
95
+ }
96
+
97
+ this.startPolling();
98
+ }
99
+
100
+ startPolling(intervalMs = 5000) {
101
+ if (this.pollTimer) {
102
+ clearInterval(this.pollTimer);
103
+ }
104
+
105
+ this.pollTimer = setInterval(async () => {
106
+ try {
107
+ await this.pollSerial();
108
+ } catch (error) {
109
+ this.log.warn('Polling cycle failed: %s', error.message);
110
+ }
111
+ }, intervalMs);
112
+
113
+ if (typeof this.pollTimer.unref === 'function') {
114
+ this.pollTimer.unref();
115
+ }
116
+ }
117
+
118
+ async poll() {
119
+ const devices = Array.from(this.devices.values());
120
+ await Promise.all(devices.map((device) => this.pollDevice(device)));
121
+ }
122
+
123
+ async pollDevice(device) {
124
+ try {
125
+ const status = await device.client.getStatus();
126
+ const nextProfile = determineProfile(status, null);
127
+
128
+ if (nextProfile !== device.profile) {
129
+ this.log.info(
130
+ 'Shelly %s profile changed from %s to %s. Rebuilding accessories.',
131
+ device.host,
132
+ device.profile || 'unknown',
133
+ nextProfile,
134
+ );
135
+ await this.refreshDeviceTopology(device, { cachedStatus: status });
136
+ return;
137
+ }
138
+
139
+ this.updateAccessoryStates(device.host, status);
140
+ } catch (error) {
141
+ this.log.warn('Polling failed for %s: %s', device.host, error.message);
142
+ }
143
+ }
144
+
145
+ async pollSerial() {
146
+ if (this.pollInFlight) {
147
+ return this.pollInFlight;
148
+ }
149
+
150
+ this.pollInFlight = this.poll().finally(() => {
151
+ this.pollInFlight = null;
152
+ });
153
+
154
+ return this.pollInFlight;
155
+ }
156
+
157
+ async refreshTopology() {
158
+ const statusesByHost = new Map();
159
+ const devices = Array.from(this.devices.values());
160
+
161
+ await Promise.all(devices.map(async (device) => {
162
+ try {
163
+ const status = await this.refreshDeviceTopology(device, { sync: false });
164
+ statusesByHost.set(device.host, status);
165
+ } catch (error) {
166
+ this.log.warn('Shelly discovery failed for %s: %s', device.host, error.message);
167
+ }
168
+ }));
169
+
170
+ this.syncAccessories(this.collectDiscoveredDescriptors());
171
+
172
+ for (const [host, status] of statusesByHost.entries()) {
173
+ this.updateAccessoryStates(host, status);
174
+ }
175
+
176
+ if (!statusesByHost.size) {
177
+ throw new Error('Could not discover any configured Shelly devices.');
178
+ }
179
+ }
180
+
181
+ async refreshDeviceTopology(device, options = {}) {
182
+ const { cachedStatus, sync = true } = options;
183
+ const [status, deviceInfo] = await Promise.all([
184
+ cachedStatus ? Promise.resolve(cachedStatus) : device.client.getStatus(),
185
+ device.client.getDeviceInfo().catch((error) => {
186
+ this.log.warn('Shelly.GetDeviceInfo failed for %s: %s', device.host, error.message);
187
+ return {};
188
+ }),
189
+ ]);
190
+
191
+ device.deviceInfo = deviceInfo || {};
192
+ device.profile = determineProfile(status, device.deviceInfo.profile);
193
+ device.descriptors = this.buildAccessoryDescriptors(device);
194
+ device.discovered = true;
195
+
196
+ if (sync) {
197
+ this.syncAccessories(this.collectDiscoveredDescriptors());
198
+ this.updateAccessoryStates(device.host, status);
199
+ }
200
+
201
+ return status;
202
+ }
203
+
204
+ collectDiscoveredDescriptors() {
205
+ const descriptors = [];
206
+
207
+ for (const device of this.devices.values()) {
208
+ if (!device.discovered) {
209
+ continue;
210
+ }
211
+
212
+ descriptors.push(...device.descriptors);
213
+ }
214
+
215
+ return descriptors;
216
+ }
217
+
218
+ buildAccessoryDescriptors(device) {
219
+ const { host, profile, displayName, showDimmers } = device;
220
+
221
+ if (profile === 'light') {
222
+ const descriptors = [];
223
+
224
+ for (let channel = 0; channel < 4; channel++) {
225
+ if (!showDimmers[channel]) {
226
+ continue;
227
+ }
228
+
229
+ const name = `${displayName} Dimmer ${channel + 1}`;
230
+ descriptors.push({
231
+ host,
232
+ kind: 'light',
233
+ channel,
234
+ name,
235
+ uuid: this.api.hap.uuid.generate(`${host}|light|${channel}`),
236
+ });
237
+ }
238
+
239
+ if (!descriptors.length) {
240
+ this.log.warn('Shelly %s is in light profile but all dimmer checkboxes are disabled.', host);
241
+ }
242
+
243
+ return descriptors;
244
+ }
245
+
246
+ return [{
247
+ host,
248
+ kind: profile,
249
+ channel: 0,
250
+ name: displayName,
251
+ uuid: this.api.hap.uuid.generate(`${host}|${profile}|0`),
252
+ }];
253
+ }
254
+
255
+ isDimmerEnabled(config, channel) {
256
+ const key = `showDimmer${channel + 1}`;
257
+ return !config || config[key] !== false;
258
+ }
259
+
260
+ inferHostFromUuid(uuid) {
261
+ for (const host of this.devices.keys()) {
262
+ for (let channel = 0; channel < 4; channel++) {
263
+ if (uuid === this.api.hap.uuid.generate(`${host}|light|${channel}`)) {
264
+ return host;
265
+ }
266
+ }
267
+
268
+ if (uuid === this.api.hap.uuid.generate(`${host}|rgb|0`)) {
269
+ return host;
270
+ }
271
+
272
+ if (uuid === this.api.hap.uuid.generate(`${host}|rgbw|0`)) {
273
+ return host;
274
+ }
275
+ }
276
+
277
+ return '';
278
+ }
279
+
280
+ getAccessoryHost(accessory) {
281
+ const contextHost = sanitizeHost(accessory.context ? accessory.context.host : '');
282
+
283
+ if (contextHost) {
284
+ return contextHost;
285
+ }
286
+
287
+ const inferredHost = this.inferHostFromUuid(accessory.UUID);
288
+
289
+ if (inferredHost) {
290
+ accessory.context.host = inferredHost;
291
+ return inferredHost;
292
+ }
293
+
294
+ return '';
295
+ }
296
+
297
+ getAccessoryDevice(accessory) {
298
+ const host = this.getAccessoryHost(accessory);
299
+
300
+ if (!host) {
301
+ throw new Error('Accessory host is missing from cached context.');
302
+ }
303
+
304
+ const device = this.devices.get(host);
305
+
306
+ if (!device) {
307
+ throw new Error(`Shelly host ${host} is not configured.`);
308
+ }
309
+
310
+ return device;
311
+ }
312
+
313
+ syncAccessories(descriptors) {
314
+ const wanted = new Map(descriptors.map((descriptor) => [descriptor.uuid, descriptor]));
315
+
316
+ for (const [uuid, accessory] of this.accessories.entries()) {
317
+ if (wanted.has(uuid)) {
318
+ continue;
319
+ }
320
+
321
+ const host = this.getAccessoryHost(accessory);
322
+ const device = host ? this.devices.get(host) : null;
323
+
324
+ if (device && !device.discovered) {
325
+ continue;
326
+ }
327
+
328
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
329
+ this.accessories.delete(uuid);
330
+ this.commandQueues.delete(uuid);
331
+ this.log.info('Removed accessory: %s', accessory.displayName);
332
+ }
333
+
334
+ for (const descriptor of descriptors) {
335
+ const existing = this.accessories.get(descriptor.uuid);
336
+
337
+ if (existing) {
338
+ existing.context.host = descriptor.host;
339
+ existing.context.kind = descriptor.kind;
340
+ existing.context.channel = descriptor.channel;
341
+ existing.context.state = existing.context.state || {};
342
+
343
+ if (existing.displayName !== descriptor.name) {
344
+ existing.displayName = descriptor.name;
345
+ }
346
+
347
+ this.configureShellyAccessory(existing);
348
+ this.api.updatePlatformAccessories([existing]);
349
+ continue;
350
+ }
351
+
352
+ const accessory = new this.api.platformAccessory(
353
+ descriptor.name,
354
+ descriptor.uuid,
355
+ this.api.hap.Categories.LIGHTBULB,
356
+ );
357
+
358
+ accessory.context.host = descriptor.host;
359
+ accessory.context.kind = descriptor.kind;
360
+ accessory.context.channel = descriptor.channel;
361
+ accessory.context.state = defaultState(descriptor.kind);
362
+
363
+ this.configureShellyAccessory(accessory);
364
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
365
+ this.accessories.set(descriptor.uuid, accessory);
366
+ this.log.info('Added accessory: %s', descriptor.name);
367
+ }
368
+ }
369
+
370
+ configureShellyAccessory(accessory) {
371
+ const host = this.getAccessoryHost(accessory);
372
+ const device = host ? this.devices.get(host) : null;
373
+ const deviceInfo = device ? device.deviceInfo : {};
374
+
375
+ const information = accessory.getService(this.Service.AccessoryInformation)
376
+ || accessory.addService(this.Service.AccessoryInformation);
377
+
378
+ information
379
+ .setCharacteristic(this.Characteristic.Manufacturer, 'Shelly')
380
+ .setCharacteristic(this.Characteristic.Model, deviceInfo.model || 'Shelly Plus RGBW PM')
381
+ .setCharacteristic(this.Characteristic.SerialNumber, deviceInfo.mac || host || 'unknown')
382
+ .setCharacteristic(this.Characteristic.FirmwareRevision, deviceInfo.ver || 'unknown');
383
+
384
+ const lightService = accessory.getService(this.Service.Lightbulb)
385
+ || accessory.addService(this.Service.Lightbulb);
386
+
387
+ lightService.setCharacteristic(this.Characteristic.Name, accessory.displayName);
388
+
389
+ const onCharacteristic = lightService.getCharacteristic(this.Characteristic.On);
390
+ resetCharacteristicHandlers(onCharacteristic);
391
+ onCharacteristic.onGet(() => this.getOn(accessory));
392
+ onCharacteristic.onSet((value) => this.setOn(accessory, value));
393
+
394
+ const brightnessCharacteristic = lightService.getCharacteristic(this.Characteristic.Brightness);
395
+ resetCharacteristicHandlers(brightnessCharacteristic);
396
+ brightnessCharacteristic.onGet(() => this.getBrightness(accessory));
397
+ brightnessCharacteristic.onSet((value) => this.setBrightness(accessory, value));
398
+
399
+ if (accessory.context.kind === 'light') {
400
+ if (lightService.testCharacteristic(this.Characteristic.Hue)) {
401
+ lightService.removeCharacteristic(lightService.getCharacteristic(this.Characteristic.Hue));
402
+ }
403
+
404
+ if (lightService.testCharacteristic(this.Characteristic.Saturation)) {
405
+ lightService.removeCharacteristic(lightService.getCharacteristic(this.Characteristic.Saturation));
406
+ }
407
+
408
+ return;
409
+ }
410
+
411
+ const hueCharacteristic = lightService.getCharacteristic(this.Characteristic.Hue);
412
+ resetCharacteristicHandlers(hueCharacteristic);
413
+ hueCharacteristic.onGet(() => this.getHue(accessory));
414
+ hueCharacteristic.onSet((value) => this.setHue(accessory, value));
415
+
416
+ const saturationCharacteristic = lightService.getCharacteristic(this.Characteristic.Saturation);
417
+ resetCharacteristicHandlers(saturationCharacteristic);
418
+ saturationCharacteristic.onGet(() => this.getSaturation(accessory));
419
+ saturationCharacteristic.onSet((value) => this.setSaturation(accessory, value));
420
+ }
421
+
422
+ getState(accessory) {
423
+ if (!accessory.context.state) {
424
+ accessory.context.state = defaultState(accessory.context.kind);
425
+ }
426
+
427
+ return accessory.context.state;
428
+ }
429
+
430
+ getOn(accessory) {
431
+ return this.getState(accessory).on;
432
+ }
433
+
434
+ getBrightness(accessory) {
435
+ return this.getState(accessory).brightness;
436
+ }
437
+
438
+ getHue(accessory) {
439
+ return this.getState(accessory).hue;
440
+ }
441
+
442
+ getSaturation(accessory) {
443
+ return this.getState(accessory).saturation;
444
+ }
445
+
446
+ async setOn(accessory, value) {
447
+ const targetOn = Boolean(value);
448
+
449
+ await this.enqueueAccessoryCommand(accessory, async () => {
450
+ const device = this.getAccessoryDevice(accessory);
451
+ const kind = accessory.context.kind;
452
+ const state = this.getState(accessory);
453
+
454
+ if (kind === 'light') {
455
+ await device.client.call('Light.Set', {
456
+ id: accessory.context.channel,
457
+ on: targetOn,
458
+ });
459
+
460
+ state.on = targetOn;
461
+
462
+ if (targetOn && state.brightness <= 0) {
463
+ state.brightness = 100;
464
+ }
465
+
466
+ this.pushStateToHomeKit(accessory, state);
467
+ return;
468
+ }
469
+
470
+ if (!targetOn) {
471
+ await device.client.call(profileToMethod(kind), { id: 0, on: false });
472
+ state.on = false;
473
+ this.pushStateToHomeKit(accessory, state);
474
+ return;
475
+ }
476
+
477
+ if (state.brightness <= 0) {
478
+ state.brightness = 100;
479
+ }
480
+
481
+ state.on = true;
482
+ const params = this.buildColorSetParams(kind, state, true);
483
+ await device.client.call(profileToMethod(kind), params);
484
+ this.pushStateToHomeKit(accessory, state);
485
+ });
486
+ }
487
+
488
+ async setBrightness(accessory, value) {
489
+ const targetBrightness = clampPercent(value);
490
+
491
+ await this.enqueueAccessoryCommand(accessory, async () => {
492
+ const device = this.getAccessoryDevice(accessory);
493
+ const kind = accessory.context.kind;
494
+ const state = this.getState(accessory);
495
+
496
+ if (kind === 'light') {
497
+ if (targetBrightness <= 0) {
498
+ await device.client.call('Light.Set', {
499
+ id: accessory.context.channel,
500
+ on: false,
501
+ });
502
+
503
+ state.on = false;
504
+ state.brightness = 0;
505
+ this.pushStateToHomeKit(accessory, state);
506
+ return;
507
+ }
508
+
509
+ await device.client.call('Light.Set', {
510
+ id: accessory.context.channel,
511
+ on: true,
512
+ brightness: targetBrightness,
513
+ });
514
+
515
+ state.on = true;
516
+ state.brightness = targetBrightness;
517
+ this.pushStateToHomeKit(accessory, state);
518
+ return;
519
+ }
520
+
521
+ if (targetBrightness <= 0) {
522
+ await device.client.call(profileToMethod(kind), { id: 0, on: false });
523
+ state.on = false;
524
+ state.brightness = 0;
525
+ this.pushStateToHomeKit(accessory, state);
526
+ return;
527
+ }
528
+
529
+ state.on = true;
530
+ state.brightness = targetBrightness;
531
+ const params = this.buildColorSetParams(kind, state, true);
532
+ await device.client.call(profileToMethod(kind), params);
533
+ this.pushStateToHomeKit(accessory, state);
534
+ });
535
+ }
536
+
537
+ async setHue(accessory, value) {
538
+ const targetHue = clampHue(value);
539
+
540
+ await this.enqueueAccessoryCommand(accessory, async () => {
541
+ const device = this.getAccessoryDevice(accessory);
542
+ const kind = accessory.context.kind;
543
+ const state = this.getState(accessory);
544
+
545
+ state.hue = targetHue;
546
+
547
+ if (!state.on) {
548
+ return;
549
+ }
550
+
551
+ const params = this.buildColorSetParams(kind, state, true);
552
+ await device.client.call(profileToMethod(kind), params);
553
+ this.pushStateToHomeKit(accessory, state);
554
+ });
555
+ }
556
+
557
+ async setSaturation(accessory, value) {
558
+ const targetSaturation = clampPercent(value);
559
+
560
+ await this.enqueueAccessoryCommand(accessory, async () => {
561
+ const device = this.getAccessoryDevice(accessory);
562
+ const kind = accessory.context.kind;
563
+ const state = this.getState(accessory);
564
+
565
+ state.saturation = targetSaturation;
566
+
567
+ if (!state.on) {
568
+ return;
569
+ }
570
+
571
+ const params = this.buildColorSetParams(kind, state, true);
572
+ await device.client.call(profileToMethod(kind), params);
573
+ this.pushStateToHomeKit(accessory, state);
574
+ });
575
+ }
576
+
577
+ buildColorSetParams(kind, state, on) {
578
+ const brightness = Math.max(1, clampPercent(state.brightness || 100));
579
+ const hue = clampHue(state.hue);
580
+ const saturation = clampPercent(state.saturation);
581
+
582
+ if (kind === 'rgbw' && saturation <= 1) {
583
+ return {
584
+ id: 0,
585
+ on,
586
+ brightness,
587
+ rgb: [0, 0, 0],
588
+ white: percentToByte(brightness),
589
+ };
590
+ }
591
+
592
+ const rgb = hsvToRgb(hue, saturation, brightness);
593
+
594
+ if (kind === 'rgbw') {
595
+ return {
596
+ id: 0,
597
+ on,
598
+ brightness,
599
+ rgb,
600
+ white: 0,
601
+ };
602
+ }
603
+
604
+ return {
605
+ id: 0,
606
+ on,
607
+ brightness,
608
+ rgb,
609
+ };
610
+ }
611
+
612
+ enqueueAccessoryCommand(accessory, task) {
613
+ const key = accessory.UUID;
614
+ const previous = this.commandQueues.get(key) || Promise.resolve();
615
+
616
+ const next = previous
617
+ .catch(() => undefined)
618
+ .then(task)
619
+ .finally(() => {
620
+ if (this.commandQueues.get(key) === next) {
621
+ this.commandQueues.delete(key);
622
+ }
623
+ });
624
+
625
+ this.commandQueues.set(key, next);
626
+ return next;
627
+ }
628
+
629
+ updateAccessoryStates(host, status) {
630
+ for (const accessory of this.accessories.values()) {
631
+ if (this.getAccessoryHost(accessory) !== host) {
632
+ continue;
633
+ }
634
+
635
+ const kind = accessory.context.kind;
636
+
637
+ if (kind === 'light') {
638
+ const channel = accessory.context.channel;
639
+ const lightStatus = status[`light:${channel}`];
640
+
641
+ if (!lightStatus) {
642
+ continue;
643
+ }
644
+
645
+ this.pushStateToHomeKit(accessory, normalizeLightStatus(lightStatus));
646
+ continue;
647
+ }
648
+
649
+ if (kind === 'rgb') {
650
+ const rgbStatus = status['rgb:0'];
651
+
652
+ if (!rgbStatus) {
653
+ continue;
654
+ }
655
+
656
+ this.pushStateToHomeKit(accessory, normalizeRgbStatus(rgbStatus));
657
+ continue;
658
+ }
659
+
660
+ if (kind === 'rgbw') {
661
+ const rgbwStatus = status['rgbw:0'];
662
+
663
+ if (!rgbwStatus) {
664
+ continue;
665
+ }
666
+
667
+ this.pushStateToHomeKit(accessory, normalizeRgbwStatus(rgbwStatus));
668
+ }
669
+ }
670
+ }
671
+
672
+ pushStateToHomeKit(accessory, nextState) {
673
+ const state = Object.assign(this.getState(accessory), nextState);
674
+ const service = accessory.getService(this.Service.Lightbulb);
675
+
676
+ if (!service) {
677
+ return;
678
+ }
679
+
680
+ service.updateCharacteristic(this.Characteristic.On, state.on);
681
+ service.updateCharacteristic(this.Characteristic.Brightness, state.brightness);
682
+
683
+ if (accessory.context.kind === 'light') {
684
+ return;
685
+ }
686
+
687
+ if (service.testCharacteristic(this.Characteristic.Hue)) {
688
+ service.updateCharacteristic(this.Characteristic.Hue, state.hue);
689
+ }
690
+
691
+ if (service.testCharacteristic(this.Characteristic.Saturation)) {
692
+ service.updateCharacteristic(this.Characteristic.Saturation, state.saturation);
693
+ }
694
+ }
695
+ }
696
+
697
+ class ShellyRpcClient {
698
+ constructor(host) {
699
+ this.requestId = 1;
700
+ this.timeoutMs = 4000;
701
+
702
+ const base = host.startsWith('http://') || host.startsWith('https://')
703
+ ? host
704
+ : `http://${host}`;
705
+
706
+ this.url = `${base.replace(/\/+$/, '')}/rpc`;
707
+ }
708
+
709
+ async getStatus() {
710
+ return this.call('Shelly.GetStatus');
711
+ }
712
+
713
+ async getDeviceInfo() {
714
+ return this.call('Shelly.GetDeviceInfo');
715
+ }
716
+
717
+ async call(method, params) {
718
+ if (typeof fetch !== 'function') {
719
+ throw new Error('Global fetch is not available. Use Node.js 18+ for this plugin.');
720
+ }
721
+
722
+ const payload = {
723
+ id: this.requestId++,
724
+ src: 'homebridge',
725
+ method,
726
+ };
727
+
728
+ if (params && Object.keys(params).length > 0) {
729
+ payload.params = params;
730
+ }
731
+
732
+ const controller = new AbortController();
733
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
734
+
735
+ let response;
736
+
737
+ try {
738
+ response = await fetch(this.url, {
739
+ method: 'POST',
740
+ headers: {
741
+ 'Content-Type': 'application/json',
742
+ },
743
+ body: JSON.stringify(payload),
744
+ signal: controller.signal,
745
+ });
746
+ } catch (error) {
747
+ if (error.name === 'AbortError') {
748
+ throw new Error(`Request timeout calling ${method}`);
749
+ }
750
+
751
+ throw new Error(`Network error calling ${method}: ${error.message}`);
752
+ } finally {
753
+ clearTimeout(timeout);
754
+ }
755
+
756
+ if (!response.ok) {
757
+ throw new Error(`HTTP ${response.status} ${response.statusText} calling ${method}`);
758
+ }
759
+
760
+ let body;
761
+
762
+ try {
763
+ body = await response.json();
764
+ } catch (error) {
765
+ throw new Error(`Invalid JSON received from ${method}`);
766
+ }
767
+
768
+ if (body && body.error) {
769
+ const message = body.error.message || JSON.stringify(body.error);
770
+ throw new Error(`Shelly RPC error for ${method}: ${message}`);
771
+ }
772
+
773
+ if (body && Object.prototype.hasOwnProperty.call(body, 'result')) {
774
+ return body.result;
775
+ }
776
+
777
+ if (body && Object.prototype.hasOwnProperty.call(body, 'params')) {
778
+ return body.params;
779
+ }
780
+
781
+ return body;
782
+ }
783
+ }
784
+
785
+ function determineProfile(status, profileHint) {
786
+ const hint = normalizeProfile(profileHint);
787
+
788
+ if (hint) {
789
+ return hint;
790
+ }
791
+
792
+ const keys = Object.keys(status || {});
793
+
794
+ if (keys.some((key) => key.startsWith('light:'))) {
795
+ return 'light';
796
+ }
797
+
798
+ if (keys.includes('rgbw:0')) {
799
+ return 'rgbw';
800
+ }
801
+
802
+ if (keys.includes('rgb:0')) {
803
+ return 'rgb';
804
+ }
805
+
806
+ throw new Error('Could not determine Shelly profile from status.');
807
+ }
808
+
809
+ function normalizeProfile(value) {
810
+ if (typeof value !== 'string') {
811
+ return null;
812
+ }
813
+
814
+ const normalized = value.trim().toLowerCase();
815
+
816
+ if (normalized === 'light' || normalized === 'rgb' || normalized === 'rgbw') {
817
+ return normalized;
818
+ }
819
+
820
+ return null;
821
+ }
822
+
823
+ function normalizeLightStatus(status) {
824
+ return {
825
+ on: Boolean(status.output),
826
+ brightness: clampPercent(status.brightness ?? (status.output ? 100 : 0)),
827
+ hue: 0,
828
+ saturation: 0,
829
+ };
830
+ }
831
+
832
+ function normalizeRgbStatus(status) {
833
+ const rgb = normalizeRgbArray(status.rgb);
834
+ const hsv = rgbToHsv(rgb[0], rgb[1], rgb[2]);
835
+
836
+ return {
837
+ on: Boolean(status.output),
838
+ brightness: clampPercent(status.brightness ?? hsv.v),
839
+ hue: hsv.h,
840
+ saturation: hsv.s,
841
+ };
842
+ }
843
+
844
+ function normalizeRgbwStatus(status) {
845
+ const rgb = normalizeRgbArray(status.rgb);
846
+ const white = clampByte(status.white ?? 0);
847
+ const hasColor = rgb.some((value) => value > 0);
848
+
849
+ const colorHsv = rgbToHsv(rgb[0], rgb[1], rgb[2]);
850
+ const fallbackBrightness = hasColor
851
+ ? colorHsv.v
852
+ : Math.round((white / 255) * 100);
853
+
854
+ const brightness = clampPercent(status.brightness ?? fallbackBrightness);
855
+
856
+ if (!hasColor && white > 0) {
857
+ return {
858
+ on: Boolean(status.output),
859
+ brightness,
860
+ hue: 0,
861
+ saturation: 0,
862
+ };
863
+ }
864
+
865
+ return {
866
+ on: Boolean(status.output),
867
+ brightness,
868
+ hue: colorHsv.h,
869
+ saturation: colorHsv.s,
870
+ };
871
+ }
872
+
873
+ function defaultState(kind) {
874
+ if (kind === 'light') {
875
+ return {
876
+ on: false,
877
+ brightness: 100,
878
+ hue: 0,
879
+ saturation: 0,
880
+ };
881
+ }
882
+
883
+ return {
884
+ on: false,
885
+ brightness: 100,
886
+ hue: 0,
887
+ saturation: 0,
888
+ };
889
+ }
890
+
891
+ function profileToMethod(profile) {
892
+ if (profile === 'rgb') {
893
+ return 'RGB.Set';
894
+ }
895
+
896
+ if (profile === 'rgbw') {
897
+ return 'RGBW.Set';
898
+ }
899
+
900
+ throw new Error(`Unsupported color profile: ${profile}`);
901
+ }
902
+
903
+ function hsvToRgb(h, s, v) {
904
+ const hue = clampHue(h);
905
+ const saturation = clampPercent(s) / 100;
906
+ const value = clampPercent(v) / 100;
907
+
908
+ const c = value * saturation;
909
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
910
+ const m = value - c;
911
+
912
+ let rPrime = 0;
913
+ let gPrime = 0;
914
+ let bPrime = 0;
915
+
916
+ if (hue >= 0 && hue < 60) {
917
+ rPrime = c;
918
+ gPrime = x;
919
+ } else if (hue >= 60 && hue < 120) {
920
+ rPrime = x;
921
+ gPrime = c;
922
+ } else if (hue >= 120 && hue < 180) {
923
+ gPrime = c;
924
+ bPrime = x;
925
+ } else if (hue >= 180 && hue < 240) {
926
+ gPrime = x;
927
+ bPrime = c;
928
+ } else if (hue >= 240 && hue < 300) {
929
+ rPrime = x;
930
+ bPrime = c;
931
+ } else {
932
+ rPrime = c;
933
+ bPrime = x;
934
+ }
935
+
936
+ return [
937
+ Math.round((rPrime + m) * 255),
938
+ Math.round((gPrime + m) * 255),
939
+ Math.round((bPrime + m) * 255),
940
+ ];
941
+ }
942
+
943
+ function rgbToHsv(r, g, b) {
944
+ const red = clampByte(r) / 255;
945
+ const green = clampByte(g) / 255;
946
+ const blue = clampByte(b) / 255;
947
+
948
+ const max = Math.max(red, green, blue);
949
+ const min = Math.min(red, green, blue);
950
+ const delta = max - min;
951
+
952
+ let hue = 0;
953
+
954
+ if (delta !== 0) {
955
+ if (max === red) {
956
+ hue = 60 * (((green - blue) / delta) % 6);
957
+ } else if (max === green) {
958
+ hue = 60 * ((blue - red) / delta + 2);
959
+ } else {
960
+ hue = 60 * ((red - green) / delta + 4);
961
+ }
962
+ }
963
+
964
+ if (hue < 0) {
965
+ hue += 360;
966
+ }
967
+
968
+ const saturation = max === 0 ? 0 : (delta / max) * 100;
969
+ const value = max * 100;
970
+
971
+ return {
972
+ h: Math.round(hue),
973
+ s: Math.round(saturation),
974
+ v: Math.round(value),
975
+ };
976
+ }
977
+
978
+ function normalizeRgbArray(rgb) {
979
+ if (!Array.isArray(rgb) || rgb.length < 3) {
980
+ return [255, 255, 255];
981
+ }
982
+
983
+ return [
984
+ clampByte(rgb[0]),
985
+ clampByte(rgb[1]),
986
+ clampByte(rgb[2]),
987
+ ];
988
+ }
989
+
990
+ function percentToByte(value) {
991
+ return clampByte(Math.round((clampPercent(value) / 100) * 255));
992
+ }
993
+
994
+ function clampPercent(value) {
995
+ const number = Number(value);
996
+
997
+ if (!Number.isFinite(number)) {
998
+ return 0;
999
+ }
1000
+
1001
+ return Math.max(0, Math.min(100, Math.round(number)));
1002
+ }
1003
+
1004
+ function clampHue(value) {
1005
+ const number = Number(value);
1006
+
1007
+ if (!Number.isFinite(number)) {
1008
+ return 0;
1009
+ }
1010
+
1011
+ if (number >= 360) {
1012
+ return 360;
1013
+ }
1014
+
1015
+ if (number <= 0) {
1016
+ return 0;
1017
+ }
1018
+
1019
+ return Math.round(number);
1020
+ }
1021
+
1022
+ function clampByte(value) {
1023
+ const number = Number(value);
1024
+
1025
+ if (!Number.isFinite(number)) {
1026
+ return 0;
1027
+ }
1028
+
1029
+ return Math.max(0, Math.min(255, Math.round(number)));
1030
+ }
1031
+
1032
+ function sanitizeHost(value) {
1033
+ if (typeof value !== 'string') {
1034
+ return '';
1035
+ }
1036
+
1037
+ return value
1038
+ .trim()
1039
+ .replace(/^https?:\/\//i, '')
1040
+ .replace(/\/+$/, '');
1041
+ }
1042
+
1043
+ function normalizeName(value) {
1044
+ if (typeof value !== 'string') {
1045
+ return '';
1046
+ }
1047
+
1048
+ return value.trim();
1049
+ }
1050
+
1051
+ function resetCharacteristicHandlers(characteristic) {
1052
+ if (typeof characteristic.removeOnGet === 'function') {
1053
+ characteristic.removeOnGet();
1054
+ }
1055
+
1056
+ if (typeof characteristic.removeOnSet === 'function') {
1057
+ characteristic.removeOnSet();
1058
+ }
1059
+
1060
+ if (typeof characteristic.removeAllListeners === 'function') {
1061
+ characteristic.removeAllListeners('get');
1062
+ characteristic.removeAllListeners('set');
1063
+ }
1064
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "homebridge-shelly-plus-rgbw-pm",
3
+ "version": "1.0.0",
4
+ "description": "Homebridge plugin for Shelly Plus RGBW PM (light, RGB, and RGBW profiles)",
5
+ "main": "index.js",
6
+ "keywords": [
7
+ "homebridge-plugin",
8
+ "homebridge",
9
+ "shelly",
10
+ "rgbw"
11
+ ],
12
+ "author": "",
13
+ "license": "MIT",
14
+ "engines": {
15
+ "homebridge": ">=1.8.0",
16
+ "node": ">=18.0.0"
17
+ }
18
+ }