homekit: merge SecuritySystem & child Sirens as one Homekit accessory (#650)

* add OnOff to SecuritySystem accessory as a switch

* BFS to reorder devices + merge and skip devices

* add some comments

* more safety checks

* embarrassing moment where I forgot this isn't BFS
This commit is contained in:
Brett Jia
2023-03-24 22:52:10 -04:00
committed by GitHub
parent e119056267
commit c5cb3ffa90
5 changed files with 125 additions and 12 deletions

View File

@@ -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<string>();
constructor() {
super();
@@ -171,6 +173,7 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
async start() {
this.log.clearAlerts();
this.mergedDevices = new Set<string>();
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<string>();
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<string>(deviceIds);
const uniqueReorderedIds = new Set<string>(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<Online>(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);

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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;
},
});

View File

@@ -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<string, string[]>, 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<string, string[]>();
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);
}