diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e328e31..ec6575c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Since version 1.0.0, we try to follow the [Semantic Versioning](https://semver.o ## [Unreleased] +### Added + +- **Adaptive Lighting**: Added support for Adaptive Lighting. Currently this needs to be enabled *manually* in the plugin configuration, using [converter specific configuration for `light`](https://z2m.dev/light.html#converter-specific-configuration-light). In a future release this might get enabled by default. (see [#30](https://github.com/itavero/homebridge-z2m/issues/30) / [#488](https://github.com/itavero/homebridge-z2m/pull/488)) + ## [1.10.0] - 2022-12-09 ### Added diff --git a/config.schema.json b/config.schema.json index 4b6f71df..3b430b33 100644 --- a/config.schema.json +++ b/config.schema.json @@ -150,6 +150,17 @@ ] } } + }, + "light": { + "title": "Light", + "type": "object", + "properties": { + "adaptive_lighting": { + "title": "Enable Adaptive Lighting", + "type": "boolean", + "required": false + } + } } } } diff --git a/docs/light.md b/docs/light.md index dac54926..ace5edef 100644 --- a/docs/light.md +++ b/docs/light.md @@ -8,4 +8,18 @@ The table below shows how the different features within this `exposes` entry are | `brightness` | published, set | [Brightness](https://developers.homebridge.io/#/characteristic/Brightness) | | | `color_temp` | published, set | [Color Temperature](https://developers.homebridge.io/#/characteristic/ColorTemperature) | | | `color_hs` | published, set | [Hue](https://developers.homebridge.io/#/characteristic/Hue) and [Saturation](https://developers.homebridge.io/#/characteristic/Saturation) | Requires nested features `hue` and `saturation`. Preferred over `color_xy`. | -| `color_xy` | published, set | [Hue](https://developers.homebridge.io/#/characteristic/Hue) and [Saturation](https://developers.homebridge.io/#/characteristic/Saturation) | Requires nested features `x` and `y`. Values translated by plugin. | \ No newline at end of file +| `color_xy` | published, set | [Hue](https://developers.homebridge.io/#/characteristic/Hue) and [Saturation](https://developers.homebridge.io/#/characteristic/Saturation) | Requires nested features `x` and `y`. Values translated by plugin. | + +## Converter specific configuration (`light`) + +- `adaptive_lighting`: Set to `true` to enable [Adaptive Lighting](https://support.apple.com/guide/iphone/control-accessories-iph0a717a8fd/ios#iph79e72e212). Apple requires a home hub for Adaptive Lighting to work. This feature is only available for lights that expose a *Color Temperature* characteristic. + +```json +{ + "converters": { + "light": { + "adaptive_lighting": true + } + } +} +``` diff --git a/src/converters/interfaces.ts b/src/converters/interfaces.ts index 312c83af..eed55c67 100644 --- a/src/converters/interfaces.ts +++ b/src/converters/interfaces.ts @@ -1,4 +1,4 @@ -import { Characteristic, Logger, Service } from 'homebridge'; +import { Characteristic, Controller, Logger, Service } from 'homebridge'; import { ExposesEntry } from '../z2mModels'; import { BasicLogger } from '../logger'; @@ -22,6 +22,8 @@ export interface BasicAccessory { isExperimentalFeatureEnabled(feature: string): boolean; getConverterConfiguration(tag: string): unknown | undefined; + + configureController(controller: Controller): void; } export interface ServiceHandler { diff --git a/src/converters/light.ts b/src/converters/light.ts index 2223659c..ea62fe2c 100644 --- a/src/converters/light.ts +++ b/src/converters/light.ts @@ -1,4 +1,4 @@ -import { BasicAccessory, ServiceCreator, ServiceHandler } from './interfaces'; +import { BasicAccessory, ServiceCreator, ServiceHandler, ConverterConfigurationRegistry } from './interfaces'; import { exposesCanBeGet, exposesCanBeSet, @@ -18,7 +18,7 @@ import { } from '../z2mModels'; import { hap } from '../hap'; import { getOrAddCharacteristic } from '../helpers'; -import { Characteristic, CharacteristicSetCallback, CharacteristicValue, Service } from 'homebridge'; +import { Characteristic, CharacteristicSetCallback, CharacteristicValue, Controller, Service } from 'homebridge'; import { CharacteristicMonitor, MappingCharacteristicMonitor, @@ -29,7 +29,21 @@ import { import { convertHueSatToXy, convertMiredColorTemperatureToHueSat, convertXyToHueSat } from '../colorhelper'; import { EXP_COLOR_MODE } from '../experimental'; +interface LightConfig { + adaptive_lighting?: boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isLightConfig = (x: any): x is LightConfig => + x !== undefined && (x.adaptive_lighting === undefined || typeof x.adaptive_lighting === 'boolean'); + export class LightCreator implements ServiceCreator { + public static readonly CONFIG_TAG = 'light'; + + constructor(converterConfigRegistry: ConverterConfigurationRegistry) { + converterConfigRegistry.registerConverterConfiguration(LightCreator.CONFIG_TAG, LightCreator.isValidConverterConfiguration); + } + createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void { exposes .filter( @@ -43,13 +57,28 @@ export class LightCreator implements ServiceCreator { } private createService(expose: ExposesEntryWithFeatures, accessory: BasicAccessory): void { + const converterConfig = accessory.getConverterConfiguration(LightCreator.CONFIG_TAG); + let adaptiveLightingEnabled = false; + if (isLightConfig(converterConfig) && converterConfig.adaptive_lighting) { + adaptiveLightingEnabled = true; + } + try { - const handler = new LightHandler(expose, accessory); + const handler = new LightHandler(expose, accessory, adaptiveLightingEnabled); accessory.registerServiceHandler(handler); } catch (error) { accessory.log.warn(`Failed to setup light for accessory ${accessory.displayName} from expose "${JSON.stringify(expose)}": ${error}`); } } + + private static isValidConverterConfiguration(config: unknown): boolean { + return isLightConfig(config); + } +} + +interface AdaptiveLightingControl extends Controller { + isAdaptiveLightingActive(): boolean; + disableAdaptiveLighting(): void; } class LightHandler implements ServiceHandler { @@ -69,13 +98,23 @@ class LightHandler implements ServiceHandler { private colorComponentAExpose: ExposesEntryWithProperty | undefined; private colorComponentBExpose: ExposesEntryWithProperty | undefined; + // Adaptive lighting + private adaptiveLighting: AdaptiveLightingControl | undefined; + private lastAdaptiveLightingTemperature: number | undefined; + private colorHueCharacteristic: Characteristic | undefined; + private colorSaturationCharacteristic: Characteristic | undefined; + // Internal cache for hue and saturation. Needed in case X/Y is used private cached_hue = 0.0; private received_hue = false; private cached_saturation = 0.0; private received_saturation = false; - constructor(expose: ExposesEntryWithFeatures, private readonly accessory: BasicAccessory) { + constructor( + expose: ExposesEntryWithFeatures, + private readonly accessory: BasicAccessory, + private readonly adaptiveLightingEnabled: boolean + ) { const endpoint = expose.endpoint; this.identifier = LightHandler.generateIdentifier(endpoint); @@ -107,6 +146,9 @@ class LightHandler implements ServiceHandler { // Color temperature this.tryCreateColorTemperature(features, service); + + // Adaptive lighting + this.tryCreateAdaptiveLighting(service); } identifier: string; @@ -133,32 +175,52 @@ class LightHandler implements ServiceHandler { } updateState(state: Record): void { - // Use color_mode to filter out the non-active color information - // to prevent "incorrect" updates (leading to "glitches" in the Home.app) - if (this.accessory.isExperimentalFeatureEnabled(EXP_COLOR_MODE) && LightHandler.KEY_COLOR_MODE in state) { - if ( - this.colorTempExpose !== undefined && - this.colorTempExpose.property in state && - state[LightHandler.KEY_COLOR_MODE] !== LightHandler.COLOR_MODE_TEMPERATURE - ) { - // Color mode is NOT Color Temperature. Remove color temperature information. - delete state[this.colorTempExpose.property]; - } + if (LightHandler.KEY_COLOR_MODE in state) { + const colorModeIsTemperature: boolean = state[LightHandler.KEY_COLOR_MODE] === LightHandler.COLOR_MODE_TEMPERATURE; + // If adaptive lighting is enabled, try to detect if the color was changed externally + // which should result in turning off adaptive lighting. + this.disableAdaptiveLightingBasedOnState(colorModeIsTemperature, state); + + // Use color_mode to filter out the non-active color information + // to prevent "incorrect" updates (leading to "glitches" in the Home.app) + if (this.accessory.isExperimentalFeatureEnabled(EXP_COLOR_MODE)) { + if (this.colorTempExpose !== undefined && this.colorTempExpose.property in state && !colorModeIsTemperature) { + // Color mode is NOT Color Temperature. Remove color temperature information. + delete state[this.colorTempExpose.property]; + } - if ( - this.colorExpose !== undefined && - this.colorExpose.property !== undefined && - this.colorExpose.property in state && - state[LightHandler.KEY_COLOR_MODE] === LightHandler.COLOR_MODE_TEMPERATURE - ) { - // Color mode is Color Temperature. Remove HS/XY color information. - delete state[this.colorExpose.property]; + if ( + this.colorExpose !== undefined && + this.colorExpose.property !== undefined && + this.colorExpose.property in state && + colorModeIsTemperature + ) { + // Color mode is Color Temperature. Remove HS/XY color information. + delete state[this.colorExpose.property]; + } } } this.monitors.forEach((m) => m.callback(state)); } + private disableAdaptiveLightingBasedOnState(colorModeIsTemperature: boolean, state: Record) { + if (this.colorTempExpose !== undefined && this.adaptiveLighting !== undefined && this.adaptiveLighting.isAdaptiveLightingActive()) { + if (!colorModeIsTemperature) { + // Must be color temperature if adaptive lighting is active + this.accessory.log.debug('adaptive_lighting: disable due to color mode change'); + this.adaptiveLighting.disableAdaptiveLighting(); + } else if (this.lastAdaptiveLightingTemperature !== undefined && this.colorTempExpose.property in state) { + const delta = Math.abs(this.lastAdaptiveLightingTemperature - (state[this.colorTempExpose.property] as number)); + // Typically we expect a small delta if the status update is caused by a change from adaptive lighting. + if (delta > 10) { + this.accessory.log.debug(`adaptive_lighting: disable due to large delta (${delta})`); + this.adaptiveLighting.disableAdaptiveLighting(); + } + } + } + } + private tryCreateColor(expose: ExposesEntryWithFeatures, service: Service) { // First see if color_hs is present this.colorExpose = expose.features.find( @@ -193,8 +255,11 @@ class LightHandler implements ServiceHandler { return; } - getOrAddCharacteristic(service, hap.Characteristic.Hue).on('set', this.handleSetHue.bind(this)); - getOrAddCharacteristic(service, hap.Characteristic.Saturation).on('set', this.handleSetSaturation.bind(this)); + this.colorHueCharacteristic = getOrAddCharacteristic(service, hap.Characteristic.Hue).on('set', this.handleSetHue.bind(this)); + this.colorSaturationCharacteristic = getOrAddCharacteristic(service, hap.Characteristic.Saturation).on( + 'set', + this.handleSetSaturation.bind(this) + ); if (this.colorExpose.name === 'color_hs') { this.monitors.push( @@ -268,6 +333,25 @@ class LightHandler implements ServiceHandler { } } + private tryCreateAdaptiveLighting(service: Service) { + // Adaptive lighting is not enabled + if (!this.adaptiveLightingEnabled) { + return; + } + + // Need at least brightness and color temperature to add Adaptive Lighting + if (this.brightnessExpose === undefined || this.colorTempExpose === undefined) { + return; + } + + this.adaptiveLighting = new hap.AdaptiveLightingController(service).on('disable', this.resetAdaptiveLightingTemperature.bind(this)); + this.accessory.configureController(this.adaptiveLighting); + } + + private resetAdaptiveLightingTemperature(): void { + this.lastAdaptiveLightingTemperature = undefined; + } + private handleSetOn(value: CharacteristicValue, callback: CharacteristicSetCallback): void { const data = {}; data[this.stateExpose.property] = (value as boolean) ? this.stateExpose.value_on : this.stateExpose.value_off; @@ -295,17 +379,22 @@ class LightHandler implements ServiceHandler { } private handleSetColorTemperature(value: CharacteristicValue, callback: CharacteristicSetCallback): void { - if (this.colorTempExpose !== undefined) { + if (this.colorTempExpose !== undefined && typeof value === 'number') { const data = {}; - if (this.colorTempExpose.value_min !== undefined && value < this.colorTempExpose.value_min) { + if (value < this.colorTempExpose.value_min) { value = this.colorTempExpose.value_min; } - if (this.colorTempExpose.value_max !== undefined && value > this.colorTempExpose.value_max) { + if (value > this.colorTempExpose.value_max) { value = this.colorTempExpose.value_max; } + data[this.colorTempExpose.property] = value; - this.accessory.queueDataForSetAction(data); + + if (this.handleAdaptiveLighting(value)) { + this.accessory.queueDataForSetAction(data); + } + callback(null); } else { callback(new Error('color temperature not supported')); @@ -345,6 +434,11 @@ class LightHandler implements ServiceHandler { if (this.received_hue && this.received_saturation) { this.received_hue = false; this.received_saturation = false; + if (this.adaptiveLighting?.isAdaptiveLightingActive()) { + // Hue/Saturation set from HomeKit, disable Adaptive Lighting + this.accessory.log.debug('adaptive_lighting: disable due to hue/sat'); + this.adaptiveLighting.disableAdaptiveLighting(); + } if ( this.colorExpose?.name === 'color_hs' && this.colorExpose?.property !== undefined && @@ -368,6 +462,11 @@ class LightHandler implements ServiceHandler { if (this.received_hue && this.received_saturation) { this.received_hue = false; this.received_saturation = false; + if (this.adaptiveLighting?.isAdaptiveLightingActive()) { + // Hue/Saturation set from HomeKit, disable Adaptive Lighting + this.accessory.log.debug('adaptive_lighting: disable due to hue/sat'); + this.adaptiveLighting.disableAdaptiveLighting(); + } if ( this.colorExpose?.name === 'color_xy' && this.colorExpose?.property !== undefined && @@ -394,6 +493,39 @@ class LightHandler implements ServiceHandler { } return identifier; } + + private handleAdaptiveLighting(value: number): boolean { + // Adaptive Lighting active? + if (this.colorTempExpose !== undefined && this.adaptiveLighting !== undefined && this.adaptiveLighting.isAdaptiveLightingActive()) { + if (this.lastAdaptiveLightingTemperature === undefined) { + this.lastAdaptiveLightingTemperature = value; + } else { + const change = Math.abs(this.lastAdaptiveLightingTemperature - value); + if (change < 1) { + this.accessory.log.debug( + `adaptive_lighting: ${this.accessory.displayName}: skipped ${this.colorTempExpose.property} (new: ${value}; ` + + `old: ${this.lastAdaptiveLightingTemperature}` + ); + return false; + } + + this.accessory.log.debug(`adaptive_lighting: ${this.accessory.displayName}: ${this.colorTempExpose.property} ${value}`); + this.lastAdaptiveLightingTemperature = value; + } + } else { + this.resetAdaptiveLightingTemperature(); + } + + return true; + } + + private updateHueAndSaturationBasedOnColorTemperature(value: number): void { + if (this.colorHueCharacteristic !== undefined && this.colorSaturationCharacteristic !== undefined) { + const color = hap.ColorUtils.colorTemperatureToHueAndSaturation(value, true); + this.colorHueCharacteristic.updateValue(color.hue); + this.colorSaturationCharacteristic.updateValue(color.saturation); + } + } } class ColorTemperatureToHueSatMonitor implements CharacteristicMonitor { diff --git a/src/docgen/docgen.ts b/src/docgen/docgen.ts index 9ca3da42..1fbcf204 100644 --- a/src/docgen/docgen.ts +++ b/src/docgen/docgen.ts @@ -115,6 +115,14 @@ const serviceNameMapping = new Map([ addServiceMapping(hapNodeJs.Service.AirQualitySensor, 'air_quality.md'), ]); +// Controllers +class ControllerMapping { + constructor(readonly displayName: string, readonly page: string) {} +} +const controllerMapping = new Map([ + ['AdaptiveLightingController', new ControllerMapping('Adaptive Lighting', 'light.md')], +]); + const servicesIgnoredForDeterminingSupport = new Set([hapNodeJs.Service.BatteryService.UUID]); const ignoredExposesNames = new Set(['linkquality', 'battery', 'battery_low']); @@ -177,7 +185,7 @@ function serviceInfoToMarkdown(info: Map): string { return result; } -function generateDevicePage(basePath: string, device: any, services: Map) { +function generateDevicePage(basePath: string, device: any, services: Map, controllers: string[]) { if (device.whiteLabelOf) { // Don't generate device page for white label products. return; @@ -249,7 +257,7 @@ The following HomeKit Services and Characteristics are exposed by ${device.whiteLabel ? 'these devices' : `the ${device.vendor} ${device.model}`} ${serviceInfoToMarkdown(services)} - +${controllersToMarkdownList(controllers)} `; if (!isSupported && hasPropertiesThatAreNotIgnored) { @@ -274,6 +282,22 @@ ${JSON.stringify(device.exposes, null, 2)} fs.writeFileSync(fileName, devicePage); } +function controllersToMarkdownList(controllers: string[]) { + let result = ''; + if (controllers.length > 0) { + result += '## Other features\n'; + for (const controller of controllers) { + const mapping = controllerMapping.get(controller); + if (mapping === undefined) { + result += `* ${controller}\n`; + } else { + result += `* [${mapping.displayName}](../../${mapping.page})\n`; + } + } + } + return result; +} + function generateExposesJson(basePath: string, device: any) { if (device.whiteLabelOf) { // Don't generate device page for white label products. @@ -316,19 +340,21 @@ for (const device of allDevices) { } // Check services for all non white label devices -function checkServicesAndCharacteristics(device: any): Map { +function checkServicesAndCharacteristics(device: any): DocsAccessory { const exposes = device.exposes.map((e) => e as ExposesEntry); const accessory = new DocsAccessory(`${device.vendor} ${device.model}`); BasicServiceCreatorManager.getInstance().createHomeKitEntitiesFromExposes(accessory, exposes); - return accessory.getServicesAndCharacteristics(); + return accessory; } allDevices.forEach((d) => { try { if (d.whiteLabelOf === undefined) { generateExposesJson(exposes_base_path, d); - const services = checkServicesAndCharacteristics(d); - generateDevicePage(docs_base_path, d, services); + const accessory = checkServicesAndCharacteristics(d); + const services = accessory.getServicesAndCharacteristics(); + const controllers = accessory.getControllerNames(); + generateDevicePage(docs_base_path, d, services, controllers); } } catch (Error) { console.log(`Problem generating device page for ${d.vendor} ${d.model}: ${Error}`); diff --git a/src/docgen/docs_accessory.ts b/src/docgen/docs_accessory.ts index fced8a1c..f86c1a2a 100644 --- a/src/docgen/docs_accessory.ts +++ b/src/docgen/docs_accessory.ts @@ -1,4 +1,4 @@ -import { Service } from 'homebridge'; +import { Controller, Service } from 'homebridge'; import { BasicAccessory, ServiceHandler } from '../converters/interfaces'; import { BasicLogger } from '../logger'; @@ -20,10 +20,17 @@ export class DocsAccessory implements BasicAccessory { private readonly services: Service[] = []; private readonly handlerIds = new Set(); + private readonly controllers = new Set(); constructor(readonly displayName: string) {} - getConverterConfiguration(): unknown { + getConverterConfiguration(tag: string): unknown | undefined { + if (tag === 'light') { + // Return a config that has adaptive lighting enabled + return { + adaptive_lighting: true, + }; + } return {}; } @@ -81,4 +88,12 @@ export class DocsAccessory implements BasicAccessory { isServiceHandlerIdKnown(identifier: string): boolean { return this.handlerIds.has(identifier); } + + configureController(controller: Controller): void { + this.controllers.add(controller.constructor.name); + } + + getControllerNames(): string[] { + return [...this.controllers].sort(); + } } diff --git a/src/platformAccessory.ts b/src/platformAccessory.ts index e0456760..a76f5664 100644 --- a/src/platformAccessory.ts +++ b/src/platformAccessory.ts @@ -1,4 +1,4 @@ -import { HAPStatus, PlatformAccessory, Service } from 'homebridge'; +import { Controller, HAPStatus, PlatformAccessory, Service } from 'homebridge'; import { Zigbee2mqttPlatform } from './platform'; import { ExtendedTimer } from './timer'; import { hap } from './hap'; @@ -482,4 +482,8 @@ export class Zigbee2mqttAccessory implements BasicAccessory { } return name; } + + configureController(controller: Controller) { + this.accessory.configureController(controller); + } } diff --git a/test/exposes/innr/rb_249_t.json b/test/exposes/innr/rb_249_t.json new file mode 100644 index 00000000..05a59742 --- /dev/null +++ b/test/exposes/innr/rb_249_t.json @@ -0,0 +1,143 @@ +[ + { + "type": "light", + "features": [ + { + "type": "binary", + "name": "state", + "property": "state", + "access": 7, + "value_on": "ON", + "value_off": "OFF", + "value_toggle": "TOGGLE", + "description": "On/off state of this light" + }, + { + "type": "numeric", + "name": "brightness", + "property": "brightness", + "access": 7, + "value_min": 0, + "value_max": 254, + "description": "Brightness of this light" + }, + { + "type": "numeric", + "name": "color_temp", + "property": "color_temp", + "access": 7, + "unit": "mired", + "value_min": 200, + "value_max": 454, + "description": "Color temperature of this light", + "presets": [ + { + "name": "coolest", + "value": 200, + "description": "Coolest temperature supported" + }, + { + "name": "cool", + "value": 250, + "description": "Cool temperature (250 mireds / 4000 Kelvin)" + }, + { + "name": "neutral", + "value": 370, + "description": "Neutral temperature (370 mireds / 2700 Kelvin)" + }, + { + "name": "warm", + "value": 454, + "description": "Warm temperature (454 mireds / 2200 Kelvin)" + }, + { + "name": "warmest", + "value": 454, + "description": "Warmest temperature supported" + } + ] + }, + { + "type": "numeric", + "name": "color_temp_startup", + "property": "color_temp_startup", + "access": 7, + "unit": "mired", + "value_min": 200, + "value_max": 454, + "description": "Color temperature after cold power on of this light", + "presets": [ + { + "name": "coolest", + "value": 200, + "description": "Coolest temperature supported" + }, + { + "name": "cool", + "value": 250, + "description": "Cool temperature (250 mireds / 4000 Kelvin)" + }, + { + "name": "neutral", + "value": 370, + "description": "Neutral temperature (370 mireds / 2700 Kelvin)" + }, + { + "name": "warm", + "value": 454, + "description": "Warm temperature (454 mireds / 2200 Kelvin)" + }, + { + "name": "warmest", + "value": 454, + "description": "Warmest temperature supported" + }, + { + "name": "previous", + "value": 65535, + "description": "Restore previous color_temp on cold power on" + } + ] + } + ] + }, + { + "type": "enum", + "name": "effect", + "property": "effect", + "access": 2, + "values": [ + "blink", + "breathe", + "okay", + "channel_change", + "finish_effect", + "stop_effect" + ], + "description": "Triggers an effect on the light (e.g. make light blink for a few seconds)" + }, + { + "type": "enum", + "name": "power_on_behavior", + "property": "power_on_behavior", + "access": 7, + "values": [ + "off", + "on", + "toggle", + "previous" + ], + "description": "Controls the behavior when the device is powered on after power loss" + }, + { + "type": "numeric", + "name": "linkquality", + "property": "linkquality", + "access": 1, + "unit": "lqi", + "description": "Link quality (signal strength)", + "value_min": 0, + "value_max": 255 + } +] \ No newline at end of file diff --git a/test/exposes/namron/4512700.json b/test/exposes/namron/4512700.json new file mode 100644 index 00000000..3aa7c530 --- /dev/null +++ b/test/exposes/namron/4512700.json @@ -0,0 +1,64 @@ +[ + { + "type": "light", + "features": [ + { + "type": "binary", + "name": "state", + "property": "state", + "access": 7, + "value_on": "ON", + "value_off": "OFF", + "value_toggle": "TOGGLE", + "description": "On/off state of this light" + }, + { + "type": "numeric", + "name": "brightness", + "property": "brightness", + "access": 7, + "value_min": 0, + "value_max": 254, + "description": "Brightness of this light" + } + ] + }, + { + "type": "enum", + "name": "effect", + "property": "effect", + "access": 2, + "values": [ + "blink", + "breathe", + "okay", + "channel_change", + "finish_effect", + "stop_effect" + ], + "description": "Triggers an effect on the light (e.g. make light blink for a few seconds)" + }, + { + "type": "enum", + "name": "power_on_behavior", + "property": "power_on_behavior", + "access": 7, + "values": [ + "off", + "on", + "toggle", + "previous" + ], + "description": "Controls the behavior when the device is powered on after power loss" + }, + { + "type": "numeric", + "name": "linkquality", + "property": "linkquality", + "access": 1, + "unit": "lqi", + "description": "Link quality (signal strength)", + "value_min": 0, + "value_max": 255 + } +] \ No newline at end of file diff --git a/test/light.spec.ts b/test/light.spec.ts index 95e5f2c2..2d5da94e 100644 --- a/test/light.spec.ts +++ b/test/light.spec.ts @@ -787,4 +787,162 @@ describe('Light', () => { harness.checkSetDataQueued({ color: { hue: 300, saturation: 100 } }); }); }); + describe('Namron Zigbee Dimmer (Adaptive Lighting ignored)', () => { + // Shared "state" + let deviceExposes: ExposesEntry[] = []; + let harness: ServiceHandlersTestHarness; + + beforeEach(() => { + // Only test service creation for first test case and reuse harness afterwards + if (deviceExposes.length === 0 && harness === undefined) { + // Load exposes from JSON + deviceExposes = loadExposesFromFile('namron/4512700.json'); + expect(deviceExposes.length).toBeGreaterThan(0); + + // Check service creation + const newHarness = new ServiceHandlersTestHarness(); + + // Enable adaptive lighting to check if it will be ignored (as this device does not have a color temperature) + newHarness.addConverterConfiguration('light', { adaptive_lighting: true }); + newHarness.numberOfExpectedControllers = 0; + + newHarness + .getOrAddHandler(hap.Service.Lightbulb) + .addExpectedCharacteristic('state', hap.Characteristic.On, true) + .addExpectedCharacteristic('brightness', hap.Characteristic.Brightness, true); + newHarness.prepareCreationMocks(); + + newHarness.callCreators(deviceExposes); + + newHarness.checkCreationExpectations(); + newHarness.checkHasMainCharacteristics(); + + newHarness.checkExpectedGetableKeys(['state', 'brightness']); + harness = newHarness; + } + + harness?.clearMocks(); + const brightnessCharacteristicMock = harness?.getOrAddHandler(hap.Service.Lightbulb).getCharacteristicMock('brightness'); + if (brightnessCharacteristicMock !== undefined) { + brightnessCharacteristicMock.props.minValue = 0; + brightnessCharacteristicMock.props.maxValue = 100; + } + }); + + afterEach(() => { + verifyAllWhenMocksCalled(); + resetAllWhenMocks(); + }); + + test('HomeKit: Turn On', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'state', true, 'ON'); + }); + + test('HomeKit: Turn Off', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'state', false, 'OFF'); + }); + }); + + describe('Innr RB-249-T (Adaptive Lighting turned on)', () => { + // Shared "state" + let deviceExposes: ExposesEntry[] = []; + let harness: ServiceHandlersTestHarness; + + beforeEach(() => { + // Only test service creation for first test case and reuse harness afterwards + if (deviceExposes.length === 0 && harness === undefined) { + // Load exposes from JSON + deviceExposes = loadExposesFromFile('innr/rb_249_t.json'); + expect(deviceExposes.length).toBeGreaterThan(0); + + // Check service creation + const newHarness = new ServiceHandlersTestHarness(); + newHarness.addConverterConfiguration('light', { adaptive_lighting: true }); + newHarness.numberOfExpectedControllers = 1; + const lightbulb = newHarness + .getOrAddHandler(hap.Service.Lightbulb) + .addExpectedCharacteristic('state', hap.Characteristic.On, true) + .addExpectedCharacteristic('brightness', hap.Characteristic.Brightness, true) + .addExpectedCharacteristic('color_temp', hap.Characteristic.ColorTemperature, true); + newHarness.prepareCreationMocks(); + + newHarness.callCreators(deviceExposes); + + newHarness.checkCreationExpectations(); + newHarness.checkHasMainCharacteristics(); + + newHarness.checkExpectedGetableKeys(['state', 'brightness', 'color_temp']); + + // Expect range of color temperature to be configured + lightbulb.checkCharacteristicPropertiesHaveBeenSet('color_temp', { + minValue: 200, + maxValue: 454, + minStep: 1, + }); + harness = newHarness; + } + + harness?.clearMocks(); + const brightnessCharacteristicMock = harness?.getOrAddHandler(hap.Service.Lightbulb).getCharacteristicMock('brightness'); + if (brightnessCharacteristicMock !== undefined) { + brightnessCharacteristicMock.props.minValue = 0; + brightnessCharacteristicMock.props.maxValue = 100; + } + }); + + afterEach(() => { + verifyAllWhenMocksCalled(); + resetAllWhenMocks(); + }); + + test('Status update is handled: State On', () => { + expect(harness).toBeDefined(); + harness.checkSingleUpdateState('{"state":"ON"}', hap.Service.Lightbulb, hap.Characteristic.On, true); + }); + + test('Status update is handled: State Off', () => { + expect(harness).toBeDefined(); + harness.checkSingleUpdateState('{"state":"OFF"}', hap.Service.Lightbulb, hap.Characteristic.On, false); + }); + + test('Status update is handled: Brightness 50%', () => { + expect(harness).toBeDefined(); + harness.getOrAddHandler(hap.Service.Lightbulb).prepareGetCharacteristicMock('brightness'); + harness.checkSingleUpdateState('{"brightness":127}', hap.Service.Lightbulb, hap.Characteristic.Brightness, 50); + }); + + test('HomeKit: Turn On', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'state', true, 'ON'); + }); + + test('HomeKit: Turn Off', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'state', false, 'OFF'); + }); + + test('HomeKit: Brightness to 50%', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'brightness', 50, 127); + }); + + describe('HomeKit: Color Temperature', () => { + test('Set to 400', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'color_temp', 400, 400); + }); + + test('Set out of bounds (low)', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'color_temp', 199, 200); + }); + + test('Set out of bounds (high)', () => { + expect(harness).toBeDefined(); + harness.checkHomeKitUpdateWithSingleValue(hap.Service.Lightbulb, 'color_temp', 455, 454); + }); + }); + }); }); diff --git a/test/testHelpers.ts b/test/testHelpers.ts index f5a53387..388d1f38 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -167,9 +167,16 @@ class ServiceHandlerTestData implements ServiceHandlerContainer { serviceHandler?: ServiceHandler; readonly serviceMock: MockProxy & Service; readonly characteristics: Map = new Map(); + readonly addedCharacteristicUUIDs = new Set(); constructor(readonly serviceUuid: string, readonly subType: string | undefined, readonly serviceIdentifier: string) { this.serviceMock = mock(); + this.serviceMock.testCharacteristic.mockImplementation((c) => { + if (typeof c === 'string') { + return false; + } + return this.addedCharacteristicUUIDs.has(c.UUID); + }); } addExpectedPropertyCheck(property: string): ServiceHandlerContainer { @@ -286,6 +293,8 @@ export class ServiceHandlersTestHarness { private readonly converterConfig = new Map(); readonly accessoryMock: MockProxy & BasicAccessory; + public numberOfExpectedControllers = 0; + constructor() { this.accessoryMock = mock(); this.accessoryMock.log = mock(); @@ -390,7 +399,12 @@ export class ServiceHandlersTestHarness { if (mapping.characteristic !== undefined) { when(data.serviceMock.getCharacteristic).calledWith(mapping.characteristic).mockReturnValue(undefined); - when(data.serviceMock.addCharacteristic).calledWith(mapping.characteristic).mockReturnValue(mapping.mock); + when(data.serviceMock.addCharacteristic) + .calledWith(mapping.characteristic) + .mockImplementation((characteristic: Characteristic) => { + data.addedCharacteristicUUIDs.add(characteristic.UUID); + return mapping.mock; + }); if (mapping.mock !== undefined) { mapping.mock.on.mockReturnThis(); @@ -432,6 +446,8 @@ export class ServiceHandlersTestHarness { let expectedCallsToGetOrAddService = 0; let expectedCallsToRegisterServiceHandler = 0; + expect(this.accessoryMock.configureController).toBeCalledTimes(this.numberOfExpectedControllers); + for (const handler of this.handlers.values()) { expect(this.accessoryMock.isServiceHandlerIdKnown).toHaveBeenCalledWith(handler.serviceIdentifier);