import { AudioStreamingCodecType, AudioStreamingSamplerate, CameraControllerOptions, CharacteristicEventTypes, CharacteristicGetCallback, CharacteristicSetCallback, CharacteristicValue, HAP, Logging, PlatformAccessory, PlatformConfig, Service, WithUUID, } from 'homebridge'; import { StreamingDelegate } from './streaming-delegate'; import { NestCam, NestCamEvents } from './nest/cam'; import { Properties } from './nest/types/camera'; type ServiceType = WithUUID<typeof Service>; const sanitizeString = (str: string): string => { if (str.includes('package')) { // Package return str.replace('-', ' ').replace(/(?:^|\s|["'([{])+\S/g, (match) => match.toUpperCase()); } else if (str.startsWith('Face') || str.startsWith('Zone')) { return str; } else { // Motion, Person, Sound return str.replace(/(?:^|\s|["'([{])+\S/g, (match) => match.toUpperCase()); } }; export class NestAccessory { private readonly log: Logging; private readonly hap: HAP; private accessory: PlatformAccessory; private camera: NestCam; private config: PlatformConfig; constructor(accessory: PlatformAccessory, camera: NestCam, config: PlatformConfig, log: Logging, hap: HAP) { this.accessory = accessory; this.camera = camera; this.config = config; this.log = log; this.hap = hap; // Setup events camera.on(NestCamEvents.CAMERA_STATE_CHANGED, (value: boolean) => { const service = this.accessory.getService(`${this.accessory.displayName} Streaming`); service && service.updateCharacteristic(this.hap.Characteristic.On, value); }); camera.on(NestCamEvents.CHIME_STATE_CHANGED, (value: boolean) => { const service = this.accessory.getService(`${this.accessory.displayName} Chime`); service && service.updateCharacteristic(this.hap.Characteristic.On, value); }); camera.on(NestCamEvents.CHIME_ASSIST_STATE_CHANGED, (value: boolean) => { const service = this.accessory.getService(`${this.accessory.displayName} Announcements`); service && service.updateCharacteristic(this.hap.Characteristic.On, value); }); camera.on(NestCamEvents.AUDIO_STATE_CHANGED, (value: boolean) => { const service = this.accessory.getService(`${this.accessory.displayName} Audio`); service && service.updateCharacteristic(this.hap.Characteristic.On, value); }); camera.on(NestCamEvents.MOTION_DETECTED, (state: boolean, alertTypes: Array<string>) => { this.setMotion(state, alertTypes); }); camera.on(NestCamEvents.DOORBELL_RANG, () => { this.setDoorbell(); }); } createService(serviceType: ServiceType, name?: string): Service { const existingService = name ? this.accessory.getServiceById(serviceType, `${this.accessory.displayName} ${name}`) : this.accessory.getService(serviceType); const service = existingService || (name ? this.accessory.addService( serviceType, `${this.accessory.displayName} ${name}`, `${this.accessory.displayName} ${name}`, ) : this.accessory.addService(serviceType, this.accessory.displayName)); return service; } removeService(serviceType: ServiceType, name?: string): void { const existingService = name ? this.accessory.getServiceById(serviceType, `${this.accessory.displayName} ${name}`) : this.accessory.getService(serviceType); if (existingService) { this.accessory.removeService(existingService); } } removeAllServicesByType(serviceType: ServiceType): void { let existingService = this.accessory.getService(serviceType); while (existingService) { this.accessory.removeService(existingService); existingService = this.accessory.getService(serviceType); } } createSwitchService( name: string, serviceType: ServiceType, _key: keyof Properties, cb: (value: CharacteristicValue) => Promise<void>, ): void { const service = this.createService(serviceType, name); this.log.debug(`Creating switch for ${this.accessory.displayName} ${name}.`); service .setCharacteristic(this.hap.Characteristic.On, this.camera.info.properties[_key]) .getCharacteristic(this.hap.Characteristic.On) .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => { cb(value); this.log.info(`Setting ${this.accessory.displayName} ${name} to ${value ? 'on' : 'off'}`); callback(); }) .on(CharacteristicEventTypes.GET, (callback: CharacteristicGetCallback) => { callback(undefined, this.camera.info.properties[_key]); }); } configureController(): void { const streamingDelegate = new StreamingDelegate(this.hap, this.camera, this.config, this.log); const options: CameraControllerOptions = { cameraStreamCount: 2, // HomeKit requires at least 2 streams, but 1 is also just fine delegate: streamingDelegate, streamingOptions: { supportedCryptoSuites: [this.hap.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80], video: { resolutions: [ [320, 180, 30], [320, 240, 15], // Apple Watch requires this configuration [320, 240, 30], [480, 270, 30], [480, 360, 30], [640, 360, 30], [640, 480, 30], [1280, 720, 30], [1280, 960, 30], [1920, 1080, 30], [1600, 1200, 30], ], codec: { profiles: [this.hap.H264Profile.BASELINE, this.hap.H264Profile.MAIN, this.hap.H264Profile.HIGH], levels: [this.hap.H264Level.LEVEL3_1, this.hap.H264Level.LEVEL3_2, this.hap.H264Level.LEVEL4_0], }, }, audio: { twoWayAudio: this.camera.info.capabilities.includes('audio.microphone'), codecs: [ { type: AudioStreamingCodecType.AAC_ELD, samplerate: AudioStreamingSamplerate.KHZ_16, }, ], }, }, }; const cameraController = new this.hap.CameraController(options); streamingDelegate.controller = cameraController; this.accessory.configureController(cameraController); } getServicesByType(serviceType: ServiceType): Array<Service> { return this.accessory.services.filter((x) => x.UUID === serviceType.UUID); } async toggleActive(enabled: boolean): Promise<void> { const service = this.accessory.getService(`${this.accessory.displayName} Streaming`); const set = await this.camera.setBooleanProperty('streaming.enabled', enabled); if (set && service) { service.updateCharacteristic(this.hap.Characteristic.On, enabled); } } async toggleChime(enabled: boolean): Promise<void> { const service = this.accessory.getService(`${this.accessory.displayName} Chime`); const set = await this.camera.setBooleanProperty('doorbell.indoor_chime.enabled', enabled); if (set && service) { service.updateCharacteristic(this.hap.Characteristic.On, enabled); } } async toggleAnnouncements(enabled: boolean): Promise<void> { const service = this.accessory.getService(`${this.accessory.displayName} Announcements`); const set = await this.camera.setBooleanProperty('doorbell.chime_assist.enabled', enabled); if (set && service) { service.updateCharacteristic(this.hap.Characteristic.On, enabled); } } async toggleAudio(enabled: boolean): Promise<void> { const service = this.accessory.getService(`${this.accessory.displayName} Audio`); const set = await this.camera.setBooleanProperty('audio.enabled', enabled); if (set && service) { service.updateCharacteristic(this.hap.Characteristic.On, enabled); } } private setMotion(state: boolean, types: Array<string>): void { if (this.hap) { types.forEach((type) => { type = sanitizeString(type); const service = this.accessory.getServiceById( this.hap.Service.MotionSensor, `${this.accessory.displayName} ${type}`, ); if (service) { this.log.debug(`Setting ${this.accessory.displayName} ${type} Motion to ${state}`); service.updateCharacteristic(this.hap.Characteristic.MotionDetected, state); } }); } } private setDoorbell(): void { const doorbellService = this.accessory.getServiceById( this.hap.Service.Doorbell, `${this.accessory.displayName} Doorbell`, ); if (doorbellService) { this.log.debug(`Ringing ${this.accessory.displayName} Doorbell`); doorbellService.updateCharacteristic( this.hap.Characteristic.ProgrammableSwitchEvent, this.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS, ); } const switchService = this.accessory.getService(this.hap.Service.StatelessProgrammableSwitch); if (switchService) { switchService.updateCharacteristic( this.hap.Characteristic.ProgrammableSwitchEvent, this.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS, ); } } }