diff --git a/plugins/homekit/src/main.ts b/plugins/homekit/src/main.ts index 5aad1f192..5fd9568af 100644 --- a/plugins/homekit/src/main.ts +++ b/plugins/homekit/src/main.ts @@ -13,6 +13,7 @@ import { addAccessoryDeviceInfo } from './info'; import { randomPinCode } from './pincode'; import './types'; import { VIDEO_CLIPS_NATIVE_ID } from './types/camera/camera-recording-files'; +import { reorderDevicesByProvider } from './util'; import { VideoClipsMixinProvider } from './video-clips-provider'; const hapStorage: Storage = { @@ -109,6 +110,7 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider, description: 'The last home hub to request a recording. Internally used to determine if a streaming request is coming from remote wifi.', }, }); + mergedDevices = new Set(); constructor() { super(); @@ -171,6 +173,7 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider, async start() { this.log.clearAlerts(); + this.mergedDevices = new Set(); let defaultIncluded: any; try { @@ -181,10 +184,27 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider, } const plugins = await systemManager.getComponent('plugins'); - const accessoryIds = new Set(); + const deviceIds = Object.keys(systemManager.getSystemState()); - for (const id of Object.keys(systemManager.getSystemState())) { + // when creating accessories in order, some DeviceProviders may merge in + // their child devices (and report back which devices are merged via + // this.mergedDevices) + // we need to ensure that the iteration processes DeviceProviders before + // their children, so a reordering is necessary + const reorderedDeviceIds = reorderDevicesByProvider(deviceIds); + + // safety checks in case something went wrong + if (deviceIds.length !== reorderedDeviceIds.length) { + throw Error(`error in device reordering, expected ${deviceIds.length} devices but only got ${reorderedDeviceIds.length}!`); + } + const uniqueDeviceIds = new Set(deviceIds); + const uniqueReorderedIds = new Set(reorderedDeviceIds); + if (uniqueDeviceIds.size !== uniqueReorderedIds.size) { + throw Error(`error in device reordering, expected ${uniqueDeviceIds.size} unique devices but only got ${uniqueReorderedIds.size} entries!`); + } + + for (const id of reorderedDeviceIds) { const device = systemManager.getDeviceById(id); const supportedType = supportedTypes[device.type]; if (!supportedType?.probe(device)) @@ -206,6 +226,11 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider, continue; } + if (this.mergedDevices.has(device.id)) { + this.console.log(`${device.name} was merged into an existing Homekit accessory and will not be exposed independently`) + continue; + } + this.console.log('adding', device.name); const accessory = await supportedType.getAccessory(device, this); diff --git a/plugins/homekit/src/types/common.ts b/plugins/homekit/src/types/common.ts index 331e28b07..47c5fd231 100644 --- a/plugins/homekit/src/types/common.ts +++ b/plugins/homekit/src/types/common.ts @@ -1,9 +1,10 @@ -import sdk, { Fan, AirQuality, AirQualitySensor, CO2Sensor, NOXSensor, PM10Sensor, PM25Sensor, ScryptedDevice, ScryptedInterface, VOCSensor, FanMode, OnOff } from "@scrypted/sdk"; +import sdk, { Fan, AirQuality, AirQualitySensor, CO2Sensor, NOXSensor, PM10Sensor, PM25Sensor, ScryptedDevice, ScryptedInterface, VOCSensor, FanMode, OnOff, DeviceProvider, ScryptedDeviceType } from "@scrypted/sdk"; import { bindCharacteristic } from "../common"; import { Accessory, Characteristic, CharacteristicEventTypes, Service, uuid } from '../hap'; import type { HomeKitPlugin } from "../main"; +import { getService as getOnOffService } from "./onoff-base"; -const { deviceManager } = sdk; +const { deviceManager, systemManager } = sdk; export function makeAccessory(device: ScryptedDevice, homekitPlugin: HomeKitPlugin, suffix?: string): Accessory { const mixinStorage = deviceManager.getMixinStorage(device.id, homekitPlugin.nativeId); @@ -11,6 +12,12 @@ export function makeAccessory(device: ScryptedDevice, homekitPlugin: HomeKitPlug return new Accessory(device.name, uuid.generate(resetId + device.id + (suffix ? '-' + suffix : ''))); } +export function getChildDevices(device: ScryptedDevice & DeviceProvider): ScryptedDevice[] { + const ids = Object.keys(systemManager.getSystemState()); + const allDevices = ids.map(id => systemManager.getDeviceById(id)); + return allDevices.filter(d => d.providerId == device.id); +} + export function addAirQualitySensor(device: ScryptedDevice & AirQualitySensor & PM10Sensor & PM25Sensor & VOCSensor & NOXSensor, accessory: Accessory): Service { if (!device.interfaces.includes(ScryptedInterface.AirQualitySensor)) return undefined; @@ -34,7 +41,7 @@ export function addAirQualitySensor(device: ScryptedDevice & AirQualitySensor & const airQualityService = accessory.addService(Service.AirQualitySensor); bindCharacteristic(device, ScryptedInterface.AirQualitySensor, airQualityService, Characteristic.AirQuality, () => airQualityToHomekit(device.airQuality)); - + if (device.interfaces.includes(ScryptedInterface.PM10Sensor)) { bindCharacteristic(device, ScryptedInterface.PM10Sensor, airQualityService, Characteristic.PM10Density, () => device.pm10Density || 0); @@ -157,4 +164,32 @@ export function addFan(device: ScryptedDevice & Fan & OnOff, accessory: Accessor } return service; +} + +/* + * addChildSirens looks for siren-type child devices of the given device provider + * and merges them as switches to the accessory represented by the device provider. + * + * Returns the services created as well as all of the child siren devices which have + * been merged. + */ +export function addChildSirens(device: ScryptedDevice & DeviceProvider, accessory: Accessory): { services: Service[], devices: (ScryptedDevice & OnOff)[] } { + if (!device.interfaces.includes(ScryptedInterface.DeviceProvider)) + return undefined; + + const children = getChildDevices(device); + const sirenDevices = []; + const services = children.map((child: ScryptedDevice & OnOff) => { + if (child.type !== ScryptedDeviceType.Siren || !child.interfaces.includes(ScryptedInterface.OnOff)) + return undefined; + + const onOffService = getOnOffService(child, accessory, Service.Switch) + sirenDevices.push(child); + return onOffService; + }); + + return { + services: services.filter(service => !!service), + devices: sirenDevices, + }; } \ No newline at end of file diff --git a/plugins/homekit/src/types/onoff-base.ts b/plugins/homekit/src/types/onoff-base.ts index f9d88e80a..348135eef 100644 --- a/plugins/homekit/src/types/onoff-base.ts +++ b/plugins/homekit/src/types/onoff-base.ts @@ -8,9 +8,7 @@ export function probe(device: DummyDevice): boolean { return device.interfaces.includes(ScryptedInterface.OnOff); } -export function getAccessory(device: ScryptedDevice & OnOff, homekitPlugin: HomeKitPlugin, serviceType: any): { accessory: Accessory, service: Service } | undefined { - const accessory = makeAccessory(device, homekitPlugin); - +export function getService(device: ScryptedDevice & OnOff, accessory: Accessory, serviceType: any): Service { const service = accessory.addService(serviceType, device.name); service.getCharacteristic(Characteristic.On) .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => { @@ -22,7 +20,12 @@ export function getAccessory(device: ScryptedDevice & OnOff, homekitPlugin: Home }) bindCharacteristic(device, ScryptedInterface.OnOff, service, Characteristic.On, () => !!device.on); + return service; +} +export function getAccessory(device: ScryptedDevice & OnOff, homekitPlugin: HomeKitPlugin, serviceType: any): { accessory: Accessory, service: Service } | undefined { + const accessory = makeAccessory(device, homekitPlugin); + const service = getService(device, accessory, serviceType); return { accessory, service, diff --git a/plugins/homekit/src/types/security.ts b/plugins/homekit/src/types/security.ts index ebdf6a28c..92d14a714 100644 --- a/plugins/homekit/src/types/security.ts +++ b/plugins/homekit/src/types/security.ts @@ -1,7 +1,7 @@ -import { SecuritySystem, SecuritySystemMode, SecuritySystemObstruction, ScryptedDevice, ScryptedDeviceType, ScryptedInterface } from '@scrypted/sdk'; +import { SecuritySystem, SecuritySystemMode, SecuritySystemObstruction, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, DeviceProvider } from '@scrypted/sdk'; import { addSupportedType, bindCharacteristic, DummyDevice } from '../common'; import { Characteristic, CharacteristicEventTypes, CharacteristicSetCallback, CharacteristicValue, Service } from '../hap'; -import { makeAccessory } from './common'; +import { makeAccessory, addChildSirens } from './common'; import type { HomeKitPlugin } from "../main"; addSupportedType({ @@ -44,7 +44,7 @@ addSupportedType({ return Characteristic.SecuritySystemCurrentState.DISARMED; } - + function toTargetState(mode: SecuritySystemMode) { switch(mode) { case SecuritySystemMode.AwayArmed: @@ -71,7 +71,7 @@ addSupportedType({ bindCharacteristic(device, ScryptedInterface.SecuritySystem, service, Characteristic.SecuritySystemCurrentState, () => toCurrentState(device.securitySystemState?.mode, device.securitySystemState?.triggered)); - + bindCharacteristic(device, ScryptedInterface.SecuritySystem, service, Characteristic.SecuritySystemTargetState, () => toTargetState(device.securitySystemState?.mode)); @@ -89,6 +89,14 @@ addSupportedType({ bindCharacteristic(device, ScryptedInterface.SecuritySystem, service, Characteristic.SecuritySystemAlarmType, () => !!device.securitySystemState?.triggered); + if (device.interfaces.includes(ScryptedInterface.DeviceProvider)) { + const { devices } = addChildSirens(device as ScryptedDevice as ScryptedDevice & DeviceProvider, accessory); + + // ensure child devices are skipped by the rest of homekit by + // reporting that they've been merged + devices.map(device => homekitPlugin.mergedDevices.add(device.id)); + } + return accessory; }, }); diff --git a/plugins/homekit/src/util.ts b/plugins/homekit/src/util.ts new file mode 100644 index 000000000..3d0f38ccc --- /dev/null +++ b/plugins/homekit/src/util.ts @@ -0,0 +1,42 @@ +import sdk, { ScryptedInterface } from '@scrypted/sdk'; + +const { systemManager } = sdk; + +/* + * flattenDeviceTree performs a modified DFS tree traversal of the given + * device mapping to produce a list of device ids. deviceId is the node + * of the tree currently being processed, where null is the root of the + * tree. + */ +function flattenDeviceTree(deviceMap: Map, deviceId: string): string[] { + const result: string[] = []; + if (!deviceMap.has(deviceId)) // no children + return result; + + const children = deviceMap.get(deviceId); + result.push(...children); + children.map(child => result.push(...flattenDeviceTree(deviceMap, child))) + return result; +} + +/* + * reorderDevicesByProvider returns a new ordering of the provided deviceIds + * where it is guaranteed that DeviceProviders are listed before their children. + */ +export function reorderDevicesByProvider(deviceIds: string[]): string[] { + const providerDeviceIdMap = new Map(); + + deviceIds.map(deviceId => { + const device = systemManager.getDeviceById(deviceId); + + // when provider id is equal to device id, this is a root-level device/plugin + const providerId = device.providerId !== device.id ? device.providerId : null; + if (providerDeviceIdMap.has(providerId)) { + providerDeviceIdMap.get(providerId).push(device.id); + } else { + providerDeviceIdMap.set(providerId, [device.id]); + } + }); + + return flattenDeviceTree(providerDeviceIdMap, null); +}