homebridge-cync-app 0.1.7 → 0.1.8

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.
@@ -0,0 +1,359 @@
1
+ // src/cync/cync-light-accessory.ts
2
+ import type { PlatformAccessory } from 'homebridge';
3
+ import type { CyncDevice, CyncDeviceMesh } from './config-client.js';
4
+ import type { CyncAccessoryContext, CyncAccessoryEnv } from './cync-accessory-helpers.js';
5
+ import { applyAccessoryInformationFromCyncDevice, hsvToRgb } from './cync-accessory-helpers.js';
6
+
7
+ export function configureCyncLightAccessory(
8
+ env: CyncAccessoryEnv,
9
+ mesh: CyncDeviceMesh,
10
+ device: CyncDevice,
11
+ accessory: PlatformAccessory,
12
+ deviceName: string,
13
+ deviceId: string,
14
+ ): void {
15
+ // If this accessory used to be a switch, remove that service
16
+ const existingSwitch = accessory.getService(env.api.hap.Service.Switch);
17
+ if (existingSwitch) {
18
+ env.log.info(
19
+ 'Cync: removing stale Switch service from %s (deviceId=%s) before configuring as Lightbulb',
20
+ deviceName,
21
+ deviceId,
22
+ );
23
+ accessory.removeService(existingSwitch);
24
+ }
25
+
26
+ const service =
27
+ accessory.getService(env.api.hap.Service.Lightbulb) ||
28
+ accessory.addService(env.api.hap.Service.Lightbulb, deviceName);
29
+
30
+ // Optionally update accessory category so UIs treat it as a light
31
+ if (accessory.category !== env.api.hap.Categories.LIGHTBULB) {
32
+ accessory.category = env.api.hap.Categories.LIGHTBULB;
33
+ }
34
+
35
+ // Populate Accessory Information from Cync metadata
36
+ applyAccessoryInformationFromCyncDevice(env.api, accessory, device, deviceName, deviceId);
37
+
38
+ // Ensure context is initialized
39
+ const ctx = accessory.context as CyncAccessoryContext;
40
+ ctx.cync = ctx.cync ?? {
41
+ meshId: mesh.id,
42
+ deviceId,
43
+ productId: device.product_id,
44
+ on: false,
45
+ };
46
+
47
+ // Remember mapping for LAN updates
48
+ env.registerAccessoryForDevice(deviceId, accessory);
49
+ env.markDeviceSeen(deviceId);
50
+ env.startPollingDevice(deviceId);
51
+
52
+ const Characteristic = env.api.hap.Characteristic;
53
+
54
+ // ----- On/Off -----
55
+ service
56
+ .getCharacteristic(Characteristic.On)
57
+ .onGet(() => {
58
+ if (env.isDeviceProbablyOffline(deviceId)) {
59
+ throw new env.api.hap.HapStatusError(
60
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
61
+ );
62
+ }
63
+
64
+ const currentOn = !!ctx.cync?.on;
65
+ env.log.info(
66
+ 'Cync: Light On.get -> %s for %s (deviceId=%s)',
67
+ String(currentOn),
68
+ deviceName,
69
+ deviceId,
70
+ );
71
+ return currentOn;
72
+ })
73
+ .onSet(async (value) => {
74
+ const cyncMeta = ctx.cync;
75
+
76
+ if (!cyncMeta?.deviceId) {
77
+ env.log.warn(
78
+ 'Cync: Light On.set called for %s but no cync.deviceId in context',
79
+ deviceName,
80
+ );
81
+ return;
82
+ }
83
+
84
+ const on = value === true || value === 1;
85
+
86
+ env.log.info(
87
+ 'Cync: Light On.set -> %s for %s (deviceId=%s)',
88
+ String(on),
89
+ deviceName,
90
+ cyncMeta.deviceId,
91
+ );
92
+
93
+ // Optimistic local cache; LAN update will confirm
94
+ cyncMeta.on = on;
95
+
96
+ try {
97
+ await env.tcpClient.setSwitchState(cyncMeta.deviceId, { on });
98
+ env.markDeviceSeen(cyncMeta.deviceId);
99
+ } catch (err) {
100
+ env.log.warn(
101
+ 'Cync: Light On.set failed for %s (deviceId=%s): %s',
102
+ deviceName,
103
+ cyncMeta.deviceId,
104
+ (err as Error).message ?? String(err),
105
+ );
106
+
107
+ throw new env.api.hap.HapStatusError(
108
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
109
+ );
110
+ }
111
+ });
112
+
113
+ // ----- Brightness (dimming via LAN combo_control) -----
114
+ service
115
+ .getCharacteristic(Characteristic.Brightness)
116
+ .onGet(() => {
117
+ if (env.isDeviceProbablyOffline(deviceId)) {
118
+ throw new env.api.hap.HapStatusError(
119
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
120
+ );
121
+ }
122
+
123
+ const current = ctx.cync?.brightness;
124
+
125
+ // If we have a cached LAN level, use it; otherwise infer from On.
126
+ if (typeof current === 'number') {
127
+ return current;
128
+ }
129
+
130
+ const on = ctx.cync?.on ?? false;
131
+ return on ? 100 : 0;
132
+ })
133
+ .onSet(async (value) => {
134
+ const cyncMeta = ctx.cync;
135
+
136
+ if (!cyncMeta?.deviceId) {
137
+ env.log.warn(
138
+ 'Cync: Light Brightness.set called for %s but no cync.deviceId in context',
139
+ deviceName,
140
+ );
141
+ return;
142
+ }
143
+
144
+ const brightness = Math.max(0, Math.min(100, Number(value)));
145
+
146
+ if (!Number.isFinite(brightness)) {
147
+ env.log.warn(
148
+ 'Cync: Light Brightness.set received invalid value=%o for %s (deviceId=%s)',
149
+ value,
150
+ deviceName,
151
+ cyncMeta.deviceId,
152
+ );
153
+ return;
154
+ }
155
+
156
+ // Optimistic cache
157
+ cyncMeta.brightness = brightness;
158
+ cyncMeta.on = brightness > 0;
159
+
160
+ env.log.info(
161
+ 'Cync: Light Brightness.set -> %d for %s (deviceId=%s)',
162
+ brightness,
163
+ deviceName,
164
+ cyncMeta.deviceId,
165
+ );
166
+
167
+ try {
168
+ // If we're in "color mode", keep the existing RGB and scale brightness via setColor();
169
+ // otherwise treat this as a white-brightness change.
170
+ if (cyncMeta.colorActive && cyncMeta.rgb) {
171
+ await env.tcpClient.setColor(
172
+ cyncMeta.deviceId,
173
+ cyncMeta.rgb,
174
+ brightness,
175
+ );
176
+ } else {
177
+ await env.tcpClient.setBrightness(cyncMeta.deviceId, brightness);
178
+ }
179
+
180
+ env.markDeviceSeen(cyncMeta.deviceId);
181
+ } catch (err) {
182
+ env.log.warn(
183
+ 'Cync: Light Brightness.set failed for %s (deviceId=%s): %s',
184
+ deviceName,
185
+ cyncMeta.deviceId,
186
+ (err as Error).message ?? String(err),
187
+ );
188
+
189
+ throw new env.api.hap.HapStatusError(
190
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
191
+ );
192
+ }
193
+ });
194
+
195
+ // ----- Hue -----
196
+ service
197
+ .getCharacteristic(Characteristic.Hue)
198
+ .onGet(() => {
199
+ if (env.isDeviceProbablyOffline(deviceId)) {
200
+ throw new env.api.hap.HapStatusError(
201
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
202
+ );
203
+ }
204
+
205
+ const hue = ctx.cync?.hue;
206
+ if (typeof hue === 'number') {
207
+ return hue;
208
+ }
209
+ // Default to 0° (red) if we have no color history
210
+ return 0;
211
+ })
212
+ .onSet(async (value) => {
213
+ const cyncMeta = ctx.cync;
214
+
215
+ if (!cyncMeta?.deviceId) {
216
+ env.log.warn(
217
+ 'Cync: Light Hue.set called for %s but no cync.deviceId in context',
218
+ deviceName,
219
+ );
220
+ return;
221
+ }
222
+
223
+ const hue = Math.max(0, Math.min(360, Number(value)));
224
+ if (!Number.isFinite(hue)) {
225
+ env.log.warn(
226
+ 'Cync: Light Hue.set received invalid value=%o for %s (deviceId=%s)',
227
+ value,
228
+ deviceName,
229
+ cyncMeta.deviceId,
230
+ );
231
+ return;
232
+ }
233
+
234
+ // Use cached saturation/brightness if available, otherwise sane defaults
235
+ const saturation =
236
+ typeof cyncMeta.saturation === 'number' ? cyncMeta.saturation : 100;
237
+
238
+ const brightness =
239
+ typeof cyncMeta.brightness === 'number' ? cyncMeta.brightness : 100;
240
+
241
+ const rgb = hsvToRgb(hue, saturation, brightness);
242
+
243
+ // Optimistic cache
244
+ cyncMeta.hue = hue;
245
+ cyncMeta.saturation = saturation;
246
+ cyncMeta.rgb = rgb;
247
+ cyncMeta.colorActive = true;
248
+ cyncMeta.on = brightness > 0;
249
+ cyncMeta.brightness = brightness;
250
+
251
+ env.log.info(
252
+ 'Cync: Light Hue.set -> %d for %s (deviceId=%s) -> rgb=(%d,%d,%d) brightness=%d',
253
+ hue,
254
+ deviceName,
255
+ cyncMeta.deviceId,
256
+ rgb.r,
257
+ rgb.g,
258
+ rgb.b,
259
+ brightness,
260
+ );
261
+
262
+ try {
263
+ await env.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
264
+ env.markDeviceSeen(cyncMeta.deviceId);
265
+ } catch (err) {
266
+ env.log.warn(
267
+ 'Cync: Light Hue.set failed for %s (deviceId=%s): %s',
268
+ deviceName,
269
+ cyncMeta.deviceId,
270
+ (err as Error).message ?? String(err),
271
+ );
272
+
273
+ throw new env.api.hap.HapStatusError(
274
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
275
+ );
276
+ }
277
+ });
278
+
279
+ // ----- Saturation -----
280
+ service
281
+ .getCharacteristic(Characteristic.Saturation)
282
+ .onGet(() => {
283
+ if (env.isDeviceProbablyOffline(deviceId)) {
284
+ throw new env.api.hap.HapStatusError(
285
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
286
+ );
287
+ }
288
+
289
+ const sat = ctx.cync?.saturation;
290
+ if (typeof sat === 'number') {
291
+ return sat;
292
+ }
293
+ return 100;
294
+ })
295
+ .onSet(async (value) => {
296
+ const cyncMeta = ctx.cync;
297
+
298
+ if (!cyncMeta?.deviceId) {
299
+ env.log.warn(
300
+ 'Cync: Light Saturation.set called for %s but no cync.deviceId in context',
301
+ deviceName,
302
+ );
303
+ return;
304
+ }
305
+
306
+ const saturation = Math.max(0, Math.min(100, Number(value)));
307
+ if (!Number.isFinite(saturation)) {
308
+ env.log.warn(
309
+ 'Cync: Light Saturation.set received invalid value=%o for %s (deviceId=%s)',
310
+ value,
311
+ deviceName,
312
+ cyncMeta.deviceId,
313
+ );
314
+ return;
315
+ }
316
+
317
+ const hue = typeof cyncMeta.hue === 'number' ? cyncMeta.hue : 0;
318
+
319
+ const brightness =
320
+ typeof cyncMeta.brightness === 'number' ? cyncMeta.brightness : 100;
321
+
322
+ const rgb = hsvToRgb(hue, saturation, brightness);
323
+
324
+ // Optimistic cache
325
+ cyncMeta.hue = hue;
326
+ cyncMeta.saturation = saturation;
327
+ cyncMeta.rgb = rgb;
328
+ cyncMeta.colorActive = true;
329
+ cyncMeta.on = brightness > 0;
330
+ cyncMeta.brightness = brightness;
331
+
332
+ env.log.info(
333
+ 'Cync: Light Saturation.set -> %d for %s (deviceId=%s) -> rgb=(%d,%d,%d) brightness=%d',
334
+ saturation,
335
+ deviceName,
336
+ cyncMeta.deviceId,
337
+ rgb.r,
338
+ rgb.g,
339
+ rgb.b,
340
+ brightness,
341
+ );
342
+
343
+ try {
344
+ await env.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
345
+ env.markDeviceSeen(cyncMeta.deviceId);
346
+ } catch (err) {
347
+ env.log.warn(
348
+ 'Cync: Light Saturation.set failed for %s (deviceId=%s): %s',
349
+ deviceName,
350
+ cyncMeta.deviceId,
351
+ (err as Error).message ?? String(err),
352
+ );
353
+
354
+ throw new env.api.hap.HapStatusError(
355
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
356
+ );
357
+ }
358
+ });
359
+ }
@@ -0,0 +1,100 @@
1
+ // src/cync/cync-switch-accessory.ts
2
+ import type { PlatformAccessory } from 'homebridge';
3
+ import type { CyncDevice, CyncDeviceMesh } from './config-client.js';
4
+ import type { CyncAccessoryContext, CyncAccessoryEnv } from './cync-accessory-helpers.js';
5
+ import { applyAccessoryInformationFromCyncDevice } from './cync-accessory-helpers.js';
6
+
7
+ export function configureCyncSwitchAccessory(
8
+ env: CyncAccessoryEnv,
9
+ mesh: CyncDeviceMesh,
10
+ device: CyncDevice,
11
+ accessory: PlatformAccessory,
12
+ deviceName: string,
13
+ deviceId: string,
14
+ ): void {
15
+ const service =
16
+ accessory.getService(env.api.hap.Service.Switch) ||
17
+ accessory.addService(env.api.hap.Service.Switch, deviceName);
18
+
19
+ const existingLight = accessory.getService(env.api.hap.Service.Lightbulb);
20
+ if (existingLight) {
21
+ env.log.info(
22
+ 'Cync: removing stale Lightbulb service from %s (deviceId=%s) before configuring as Switch',
23
+ deviceName,
24
+ deviceId,
25
+ );
26
+ accessory.removeService(existingLight);
27
+ }
28
+
29
+ applyAccessoryInformationFromCyncDevice(env.api, accessory, device, deviceName, deviceId);
30
+
31
+ // Ensure context is initialized
32
+ const ctx = accessory.context as CyncAccessoryContext;
33
+ ctx.cync = ctx.cync ?? {
34
+ meshId: mesh.id,
35
+ deviceId,
36
+ productId: device.product_id,
37
+ on: false,
38
+ };
39
+
40
+ // Remember mapping for LAN updates
41
+ env.registerAccessoryForDevice(deviceId, accessory);
42
+ env.markDeviceSeen(deviceId);
43
+ env.startPollingDevice(deviceId);
44
+
45
+ service
46
+ .getCharacteristic(env.api.hap.Characteristic.On)
47
+ .onGet(() => {
48
+ if (env.isDeviceProbablyOffline(deviceId)) {
49
+ throw new env.api.hap.HapStatusError(
50
+ env.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
51
+ );
52
+ }
53
+
54
+ const currentOn = !!ctx.cync?.on;
55
+ env.log.info(
56
+ 'Cync: On.get -> %s for %s (deviceId=%s)',
57
+ String(currentOn),
58
+ deviceName,
59
+ deviceId,
60
+ );
61
+ return currentOn;
62
+ })
63
+ .onSet(async (value) => {
64
+ const cyncMeta = ctx.cync;
65
+
66
+ if (!cyncMeta?.deviceId) {
67
+ env.log.warn(
68
+ 'Cync: Light On.set called for %s but no cync.deviceId in context',
69
+ deviceName,
70
+ );
71
+ return;
72
+ }
73
+
74
+ const on = value === true || value === 1;
75
+
76
+ env.log.info(
77
+ 'Cync: Light On.set -> %s for %s (deviceId=%s)',
78
+ String(on),
79
+ deviceName,
80
+ cyncMeta.deviceId,
81
+ );
82
+
83
+ cyncMeta.on = on;
84
+
85
+ if (!on) {
86
+ // Off is always a plain power packet
87
+ await env.tcpClient.setSwitchState(cyncMeta.deviceId, { on: false });
88
+ return;
89
+ }
90
+
91
+ // Turning on:
92
+ // - If we were in color mode with a known RGB + brightness, restore color.
93
+ // - Otherwise, just send a basic power-on packet.
94
+ if (cyncMeta.colorActive && cyncMeta.rgb && typeof cyncMeta.brightness === 'number') {
95
+ await env.tcpClient.setColor(cyncMeta.deviceId, cyncMeta.rgb, cyncMeta.brightness);
96
+ } else {
97
+ await env.tcpClient.setSwitchState(cyncMeta.deviceId, { on: true });
98
+ }
99
+ });
100
+ }
@@ -31,8 +31,9 @@ export class CyncTokenStore {
31
31
  const raw = await fs.readFile(this.filePath, 'utf8');
32
32
  const data = JSON.parse(raw) as CyncTokenData;
33
33
 
34
- // If expiresAt is set and in the past, treat as invalid
35
- if (data.expiresAt && data.expiresAt <= Date.now()) {
34
+ const now = Date.now();
35
+
36
+ if (typeof data.expiresAt === 'number' && data.expiresAt <= now) {
36
37
  return null;
37
38
  }
38
39