homebridge-tsvesync 1.3.11 → 1.3.13

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,231 @@
1
+ import { API, Logger } from 'homebridge';
2
+ import { TSVESyncPlatform } from '../../platform';
3
+ import { VeSync } from 'tsvesync';
4
+ import { AirQualitySensorAccessory } from '../../accessories/air-quality-sensor.accessory';
5
+ import { createMockLogger, createMockVeSync } from '../utils/test-helpers';
6
+
7
+ interface MockAirQualityDevice {
8
+ deviceName: string;
9
+ deviceType: string;
10
+ cid: string;
11
+ uuid: string;
12
+ deviceStatus: string;
13
+ airQualityValue: number;
14
+ details: Record<string, any>;
15
+ getDetails: jest.Mock;
16
+ getNormalizedAirQuality?: jest.Mock;
17
+ airQualityLevel?: number;
18
+ airQualityLabel?: string;
19
+ }
20
+
21
+ describe('AirQualitySensorAccessory', () => {
22
+ let mockAPI: jest.Mocked<API>;
23
+ let platform: TSVESyncPlatform;
24
+ let logger: jest.Mocked<Logger>;
25
+ let api: jest.Mocked<VeSync>;
26
+
27
+ const AirQualityEnum = {
28
+ UNKNOWN: 0,
29
+ EXCELLENT: 1,
30
+ GOOD: 2,
31
+ FAIR: 3,
32
+ INFERIOR: 4,
33
+ POOR: 5,
34
+ } as const;
35
+
36
+ const ServiceEnum = {
37
+ AirQualitySensor: 'AirQualitySensor',
38
+ AccessoryInformation: 'AccessoryInformation',
39
+ } as const;
40
+
41
+ const createDevice = (overrides: Partial<MockAirQualityDevice> = {}): MockAirQualityDevice => ({
42
+ deviceName: overrides.deviceName || 'Dining Room AQ',
43
+ deviceType: overrides.deviceType || 'Core300S',
44
+ cid: overrides.cid || 'cid-123',
45
+ uuid: overrides.uuid || 'uuid-123',
46
+ deviceStatus: overrides.deviceStatus || 'on',
47
+ airQualityValue: overrides.airQualityValue ?? 18,
48
+ details: overrides.details || {
49
+ air_quality_value: overrides.airQualityValue ?? 18,
50
+ air_quality_level: overrides.airQualityLevel,
51
+ air_quality: overrides.airQualityLabel ?? 'good',
52
+ pm10: 22,
53
+ },
54
+ getDetails: overrides.getDetails || jest.fn().mockResolvedValue(true),
55
+ getNormalizedAirQuality: overrides.getNormalizedAirQuality,
56
+ airQualityLevel: overrides.airQualityLevel,
57
+ airQualityLabel: overrides.airQualityLabel,
58
+ });
59
+
60
+ const buildAccessory = (device: MockAirQualityDevice) => {
61
+ const airQualityService = {
62
+ getCharacteristic: jest.fn().mockImplementation((characteristic) => {
63
+ if (characteristic === AirQualityEnum) {
64
+ return {
65
+ setProps: jest.fn().mockReturnThis(),
66
+ onGet: jest.fn().mockReturnThis(),
67
+ updateValue: jest.fn().mockReturnThis(),
68
+ };
69
+ }
70
+ return {
71
+ onGet: jest.fn().mockReturnThis(),
72
+ updateValue: jest.fn().mockReturnThis(),
73
+ setProps: jest.fn().mockReturnThis(),
74
+ };
75
+ }),
76
+ setCharacteristic: jest.fn().mockReturnThis(),
77
+ updateCharacteristic: jest.fn().mockReturnThis(),
78
+ } as any;
79
+
80
+ const infoService = {
81
+ setCharacteristic: jest.fn().mockReturnThis(),
82
+ } as any;
83
+
84
+ const accessory = {
85
+ getService: jest.fn((service) => {
86
+ if (service === ServiceEnum.AccessoryInformation) return infoService;
87
+ if (service === ServiceEnum.AirQualitySensor) return null;
88
+ return null;
89
+ }),
90
+ addService: jest.fn((service) => {
91
+ if (service === ServiceEnum.AirQualitySensor) {
92
+ return airQualityService;
93
+ }
94
+ return airQualityService;
95
+ }),
96
+ } as any;
97
+
98
+ const accessoryInstance = new AirQualitySensorAccessory(platform, accessory, device as any);
99
+
100
+ return {
101
+ accessoryInstance,
102
+ airQualityService,
103
+ infoService,
104
+ accessory,
105
+ };
106
+ };
107
+
108
+ beforeEach(() => {
109
+ logger = createMockLogger();
110
+ api = createMockVeSync();
111
+
112
+ mockAPI = {
113
+ hap: {
114
+ Characteristic: {
115
+ AirQuality: AirQualityEnum,
116
+ PM2_5Density: 'PM2_5Density',
117
+ PM10Density: 'PM10Density',
118
+ Manufacturer: 'Manufacturer',
119
+ Model: 'Model',
120
+ SerialNumber: 'SerialNumber',
121
+ Name: 'Name',
122
+ },
123
+ Service: ServiceEnum,
124
+ uuid: {
125
+ generate: jest.fn(),
126
+ },
127
+ },
128
+ platformAccessory: jest.fn(),
129
+ } as unknown as jest.Mocked<API>;
130
+
131
+ platform = new TSVESyncPlatform(logger, { debug: false, retry: { maxRetries: 3 } } as any, mockAPI);
132
+ (platform as any).api = api;
133
+ });
134
+
135
+ it('configures AirQuality characteristic to expose VeSync levels', () => {
136
+ const device = createDevice({
137
+ getNormalizedAirQuality: jest.fn().mockReturnValue({ level: 2, label: 'good' }),
138
+ airQualityLevel: 2,
139
+ airQualityLabel: 'good',
140
+ });
141
+
142
+ const { airQualityService } = buildAccessory(device);
143
+
144
+ expect(airQualityService.getCharacteristic).toHaveBeenCalledWith(AirQualityEnum);
145
+ const setter = airQualityService.getCharacteristic.mock.results[0].value.setProps;
146
+ expect(setter).toHaveBeenCalledWith({
147
+ validValues: [
148
+ AirQualityEnum.UNKNOWN,
149
+ AirQualityEnum.EXCELLENT,
150
+ AirQualityEnum.GOOD,
151
+ AirQualityEnum.FAIR,
152
+ AirQualityEnum.INFERIOR,
153
+ ],
154
+ });
155
+ });
156
+
157
+ it('returns normalized air quality levels when provided by the device', async () => {
158
+ const device = createDevice({
159
+ getNormalizedAirQuality: jest.fn().mockReturnValue({ level: 3, label: 'moderate' }),
160
+ airQualityValue: 42,
161
+ });
162
+
163
+ const { accessoryInstance } = buildAccessory(device);
164
+
165
+ const value = await (accessoryInstance as any).getAirQuality();
166
+ expect(value).toBe(AirQualityEnum.FAIR);
167
+ expect(device.getNormalizedAirQuality).toHaveBeenCalled();
168
+ });
169
+
170
+ it('falls back to direct level properties when normalized metadata is unavailable', async () => {
171
+ const device = createDevice({
172
+ getNormalizedAirQuality: undefined,
173
+ airQualityLevel: 4,
174
+ details: {
175
+ air_quality_level: 4,
176
+ air_quality_value: 80,
177
+ },
178
+ airQualityValue: 80,
179
+ });
180
+
181
+ const { accessoryInstance } = buildAccessory(device);
182
+
183
+ const value = await (accessoryInstance as any).getAirQuality();
184
+ expect(value).toBe(AirQualityEnum.INFERIOR);
185
+ });
186
+
187
+ it('falls back to PM2.5 heuristic when no level information is available', async () => {
188
+ const device = createDevice({
189
+ getNormalizedAirQuality: undefined,
190
+ airQualityLevel: undefined,
191
+ details: {
192
+ air_quality_value: 120,
193
+ },
194
+ airQualityValue: 120,
195
+ });
196
+
197
+ const { accessoryInstance } = buildAccessory(device);
198
+
199
+ const value = await (accessoryInstance as any).getAirQuality();
200
+ expect(value).toBe(AirQualityEnum.INFERIOR);
201
+ });
202
+
203
+ it('clamps PM2.5 density to HomeKit-supported range', async () => {
204
+ const device = createDevice({
205
+ airQualityValue: 1450,
206
+ details: {
207
+ air_quality_value: 1450,
208
+ },
209
+ });
210
+
211
+ const { accessoryInstance } = buildAccessory(device);
212
+
213
+ const density = await (accessoryInstance as any).getPM25Density();
214
+ expect(density).toBe(1000);
215
+ });
216
+
217
+ it('updates characteristics during refresh cycle', async () => {
218
+ const device = createDevice({
219
+ airQualityValue: 24,
220
+ getNormalizedAirQuality: jest.fn().mockReturnValue({ level: 2, label: 'good' }),
221
+ });
222
+
223
+ const { accessoryInstance, airQualityService } = buildAccessory(device);
224
+
225
+ await (accessoryInstance as any).updateCharacteristics();
226
+
227
+ expect(device.getDetails).toHaveBeenCalled();
228
+ expect(airQualityService.updateCharacteristic).toHaveBeenCalledWith(AirQualityEnum, AirQualityEnum.GOOD);
229
+ expect(airQualityService.updateCharacteristic).toHaveBeenCalledWith('PM2_5Density', 24);
230
+ });
231
+ });
@@ -299,7 +299,7 @@ describe('BaseAccessory', () => {
299
299
  expect((accessory as any).convertAirQualityToHomeKit(30)).toBe(2); // GOOD
300
300
  expect((accessory as any).convertAirQualityToHomeKit(50)).toBe(3); // FAIR
301
301
  expect((accessory as any).convertAirQualityToHomeKit(100)).toBe(4); // INFERIOR
302
- expect((accessory as any).convertAirQualityToHomeKit(200)).toBe(5); // POOR
302
+ expect((accessory as any).convertAirQualityToHomeKit(200)).toBe(4); // INFERIOR
303
303
  });
304
304
 
305
305
  it('should persist device state', async () => {
@@ -309,4 +309,4 @@ describe('BaseAccessory', () => {
309
309
  expect(mockAccessory.context.device.details.testKey).toBe('testValue');
310
310
  });
311
311
  });
312
- });
312
+ });
@@ -547,6 +547,7 @@ describe('Light Device Tests', () => {
547
547
 
548
548
  await onCharacteristic._onSet(true);
549
549
 
550
+ expect(dimmer.turnOn).toHaveBeenCalled();
550
551
  expect(dimmer.setBrightness).toHaveBeenCalledWith(37);
551
552
  });
552
553
 
@@ -556,10 +557,13 @@ describe('Light Device Tests', () => {
556
557
 
557
558
  await onCharacteristic._onSet(true);
558
559
  await onCharacteristic._onSet(false);
560
+ expect(dimmer.turnOn).toHaveBeenCalledTimes(1);
561
+ expect(dimmer.turnOff).toHaveBeenCalledTimes(1);
559
562
  dimmer.setBrightness.mockClear();
560
563
 
561
564
  await onCharacteristic._onSet(true);
562
565
 
566
+ expect(dimmer.turnOn).toHaveBeenCalledTimes(2);
563
567
  expect(dimmer.setBrightness).toHaveBeenCalledWith(45);
564
568
  });
565
569
 
@@ -43,7 +43,6 @@ export class AirQualitySensorAccessory extends BaseAccessory {
43
43
  this.platform.Characteristic.AirQuality.GOOD,
44
44
  this.platform.Characteristic.AirQuality.FAIR,
45
45
  this.platform.Characteristic.AirQuality.INFERIOR,
46
- this.platform.Characteristic.AirQuality.POOR,
47
46
  ],
48
47
  })
49
48
  .onGet(this.getAirQuality.bind(this));
@@ -91,12 +90,15 @@ export class AirQualitySensorAccessory extends BaseAccessory {
91
90
  */
92
91
  async getAirQuality(): Promise<CharacteristicValue> {
93
92
  try {
94
- // Use the device's native getter for air quality value (PM2.5)
95
- const extendedDevice = this.parentDevice as any;
96
- const pm25 = extendedDevice.airQualityValue || 0;
97
-
98
- this.platform.log.debug(`${this.device.deviceName} AQ: PM2.5 value = ${pm25}`);
99
-
93
+ const level = this.readNormalizedAirQualityLevel();
94
+ if (level >= 1) {
95
+ const hkValue = this.mapToHomeKitAirQuality(level);
96
+ this.platform.log.debug(`${this.device.deviceName} AQ: Normalized air quality level = ${level}`);
97
+ return hkValue;
98
+ }
99
+
100
+ const pm25 = this.getDevicePm25();
101
+ this.platform.log.debug(`${this.device.deviceName} AQ: Falling back to PM2.5 heuristic (${pm25})`);
100
102
  return this.convertPM25ToAirQuality(pm25);
101
103
  } catch (error) {
102
104
  this.platform.log.error(`${this.device.deviceName} AQ: Failed to get air quality:`, error);
@@ -109,9 +111,7 @@ export class AirQualitySensorAccessory extends BaseAccessory {
109
111
  */
110
112
  async getPM25Density(): Promise<CharacteristicValue> {
111
113
  try {
112
- // Use the device's native getter for air quality value (PM2.5)
113
- const extendedDevice = this.parentDevice as any;
114
- const pm25 = extendedDevice.airQualityValue || 0;
114
+ const pm25 = this.getDevicePm25();
115
115
 
116
116
  // HomeKit expects PM2.5 in μg/m³, range 0-1000
117
117
  return Math.min(1000, Math.max(0, pm25));
@@ -148,11 +148,89 @@ export class AirQualitySensorAccessory extends BaseAccessory {
148
148
  return this.platform.Characteristic.AirQuality.GOOD; // Moderate (12.1-35.4 μg/m³)
149
149
  } else if (pm25 <= 55) {
150
150
  return this.platform.Characteristic.AirQuality.FAIR; // Unhealthy for Sensitive (35.5-55.4 μg/m³)
151
- } else if (pm25 <= 150) {
152
- return this.platform.Characteristic.AirQuality.INFERIOR; // Unhealthy (55.5-150.4 μg/m³)
153
- } else {
154
- return this.platform.Characteristic.AirQuality.POOR; // Very Unhealthy (>150.5 μg/m³)
155
151
  }
152
+ return this.platform.Characteristic.AirQuality.INFERIOR;
153
+ }
154
+
155
+ private mapToHomeKitAirQuality(level: number): CharacteristicValue {
156
+ switch (level) {
157
+ case 1:
158
+ return this.platform.Characteristic.AirQuality.EXCELLENT;
159
+ case 2:
160
+ return this.platform.Characteristic.AirQuality.GOOD;
161
+ case 3:
162
+ return this.platform.Characteristic.AirQuality.FAIR;
163
+ case 4:
164
+ return this.platform.Characteristic.AirQuality.INFERIOR;
165
+ default:
166
+ return this.platform.Characteristic.AirQuality.UNKNOWN;
167
+ }
168
+ }
169
+
170
+ private readNormalizedAirQualityLevel(): number {
171
+ const extendedDevice = this.parentDevice as any;
172
+
173
+ if (typeof extendedDevice.getNormalizedAirQuality === 'function') {
174
+ try {
175
+ const normalized = extendedDevice.getNormalizedAirQuality();
176
+ if (normalized && typeof normalized.level === 'number' && normalized.level >= 1) {
177
+ return normalized.level;
178
+ }
179
+ } catch (error) {
180
+ this.platform.log.debug(`${this.device.deviceName} AQ: getNormalizedAirQuality failed`, error);
181
+ }
182
+ }
183
+
184
+ const directLevel = extendedDevice.airQualityLevel ?? extendedDevice.details?.air_quality_level;
185
+ if (typeof directLevel === 'number' && directLevel >= 1) {
186
+ return directLevel;
187
+ }
188
+
189
+ const normalized = this.normalizeAirQuality(
190
+ extendedDevice.airQuality ?? extendedDevice.details?.air_quality ?? extendedDevice.airQualityLabel
191
+ );
192
+
193
+ return normalized.level;
194
+ }
195
+
196
+ private getDevicePm25(): number {
197
+ const extendedDevice = this.parentDevice as any;
198
+ const pm25 = extendedDevice.airQualityValue ?? extendedDevice.details?.air_quality_value ?? extendedDevice.pm25 ?? 0;
199
+ return Number.isFinite(pm25) ? pm25 : 0;
200
+ }
201
+
202
+ private normalizeAirQuality(value: unknown): { level: number; label: string } {
203
+ const stringMap: Record<string, number> = {
204
+ 'excellent': 1,
205
+ 'very good': 1,
206
+ 'good': 2,
207
+ 'moderate': 3,
208
+ 'fair': 3,
209
+ 'inferior': 4,
210
+ 'poor': 4,
211
+ 'bad': 4,
212
+ };
213
+
214
+ if (typeof value === 'number') {
215
+ const level = Number.isFinite(value) ? Math.trunc(value) : -1;
216
+ if (level >= 1 && level <= 4) {
217
+ return {
218
+ level,
219
+ label: level === 1 ? 'excellent' : level === 2 ? 'good' : level === 3 ? 'moderate' : 'poor',
220
+ };
221
+ }
222
+ } else if (typeof value === 'string') {
223
+ const normalized = value.trim().toLowerCase();
224
+ const level = stringMap[normalized];
225
+ if (level) {
226
+ return {
227
+ level,
228
+ label: level === 1 ? 'excellent' : level === 2 ? 'good' : level === 3 ? 'moderate' : 'poor',
229
+ };
230
+ }
231
+ }
232
+
233
+ return { level: -1, label: 'unknown' };
156
234
  }
157
235
 
158
236
  /**
@@ -234,4 +312,4 @@ export class AirQualitySensorAccessory extends BaseAccessory {
234
312
  identify(): void {
235
313
  this.platform.log.info(`${this.device.deviceName} AQ Sensor: Identify!`);
236
314
  }
237
- }
315
+ }
@@ -226,8 +226,7 @@ export abstract class BaseAccessory {
226
226
  if (pm25 <= 12) return 1; // EXCELLENT
227
227
  if (pm25 <= 35) return 2; // GOOD
228
228
  if (pm25 <= 55) return 3; // FAIR
229
- if (pm25 <= 150) return 4; // INFERIOR
230
- return 5; // POOR
229
+ return 4; // INFERIOR (VeSync exposes four levels)
231
230
  }
232
231
 
233
232
  /**
@@ -278,8 +278,34 @@ export class LightAccessory extends BaseAccessory {
278
278
  const isOn = value as boolean;
279
279
 
280
280
  if (this.isDimmerDevice) {
281
- const targetBrightness = isOn ? this.resolveDimmerOnBrightness() : 0;
282
- await this.setBrightness(targetBrightness);
281
+ const dimmer = this.device as VeSyncDimmerSwitch;
282
+ if (isOn) {
283
+ const toggled = await dimmer.turnOn();
284
+ if (!toggled) {
285
+ throw new Error('Failed to turn on device');
286
+ }
287
+
288
+ const targetBrightness = this.resolveDimmerOnBrightness();
289
+ if (targetBrightness > 0) {
290
+ const brightnessSet = await dimmer.setBrightness(targetBrightness);
291
+ if (!brightnessSet) {
292
+ throw new Error(`Failed to restore brightness to ${targetBrightness}`);
293
+ }
294
+ this.lastKnownDimmerBrightness = targetBrightness;
295
+ this.updateCharacteristicValue(this.platform.Characteristic.Brightness, targetBrightness);
296
+ await this.persistDeviceState('brightness', targetBrightness);
297
+ }
298
+ } else {
299
+ const toggled = await dimmer.turnOff();
300
+ if (!toggled) {
301
+ throw new Error('Failed to turn off device');
302
+ }
303
+ this.updateCharacteristicValue(this.platform.Characteristic.Brightness, 0);
304
+ await this.persistDeviceState('brightness', 0);
305
+ }
306
+
307
+ await this.persistDeviceState('deviceStatus', isOn ? 'on' : 'off');
308
+ await this.refreshDimmerDetails();
283
309
  return;
284
310
  }
285
311
 
@@ -67,10 +67,13 @@ export interface VeSyncDeviceWithHumidity extends VeSyncDeviceWithPower {
67
67
 
68
68
  export interface VeSyncDeviceWithAirQuality extends VeSyncDeviceWithPower {
69
69
  airQuality: number;
70
+ airQualityLevel?: number;
71
+ airQualityLabel?: string;
70
72
  pm25?: number;
71
73
  filterLife: number;
72
74
  mode: string;
73
75
  setMode(mode: string): Promise<boolean>;
76
+ getNormalizedAirQuality?(): { level: number; label: string };
74
77
  }
75
78
 
76
79
  export interface VeSyncDeviceWithControls extends VeSyncDeviceWithPower {