import qrcode from '@koush/qrcode-terminal'; import { SettingsMixinDeviceOptions } from '@scrypted/common/src/settings-mixin'; import { sleep } from '@scrypted/common/src/sleep'; import sdk, { DeviceProvider, MixinProvider, Online, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedInterfaceProperty, Setting, Settings } from '@scrypted/sdk'; import { randomBytes } from 'crypto'; import os from 'os'; import packageJson from "../package.json"; import { maybeAddBatteryService } from './battery'; import { CameraMixin, canCameraMixin } from './camera-mixin'; import { HomeKitSession, SnapshotThrottle, supportedTypes } from './common'; import { Accessory, Bridge, Categories, Characteristic, ControllerStorage, EventedHTTPServer, MDNSAdvertiser, PublishInfo, Service } from './hap'; import { getHAPUUID, initializeHapStorage, typeToCategory } from './hap-utils'; import { HomekitMixin, HOMEKIT_MIXIN } from './homekit-mixin'; import { randomPinCode } from './pincode'; import './types'; import { VIDEO_CLIPS_NATIVE_ID } from './types/camera/camera-recording-files'; import { VideoClipsMixinProvider } from './video-clips-provider'; const { systemManager, deviceManager } = sdk; initializeHapStorage(); const includeToken = 4; function getAddresses() { const addresses = Object.entries(os.networkInterfaces()).filter(([iface]) => iface.startsWith('en') || iface.startsWith('eth') || iface.startsWith('wlan')).map(([_, addr]) => addr).flat().map(info => info.address).filter(address => address); return addresses; } class HomeKit extends ScryptedDeviceBase implements MixinProvider, Settings, HomeKitSession, DeviceProvider { bridge = new Bridge('Scrypted', getHAPUUID(this.storage)); snapshotThrottles = new Map(); pincode = randomPinCode(); homekitConnections = new Set(); standalones = new Map(); videoClips: VideoClipsMixinProvider; videoClipsId: string; cameraMixins = new Map(); constructor() { super(); this.start(); if (this.storage.getItem('blankSnapshots') === 'true') { this.storage.removeItem('blankSnapshots'); this.log.a(`The "Never Wait for Snapshots" setting has been moved to the Snapshot Plugin. Install the plugin and enable it on your preferred cameras. origin:/#/component/plugin/install/@scrypted/snapshot`); } (async() => { await deviceManager.onDevicesChanged({ devices: [ { name: 'Save HomeKit Video Clips', nativeId: VIDEO_CLIPS_NATIVE_ID, type: ScryptedDeviceType.DataSource, interfaces: [ ScryptedInterface.VideoClips, ScryptedInterface.MixinProvider, ScryptedInterface.Settings, ScryptedInterface.Readme, ], } ] }); this.videoClips = new VideoClipsMixinProvider(VIDEO_CLIPS_NATIVE_ID); this.videoClipsId = this.videoClips.id; })(); } getDevice(nativeId: string) { if (nativeId === VIDEO_CLIPS_NATIVE_ID) return this.videoClips; throw new Error('unknown device: ' + nativeId); } getUsername(storage: Storage) { let username = storage.getItem("mac"); // the HAP sample uses the mac address, but this is problematic if running // side by side with homebridge, multi instance, etc. if (!username) { const buffers = []; for (let i = 0; i < 6; i++) { buffers.push(randomBytes(1).toString('hex')); } username = buffers.join(':'); storage.setItem('mac', username); } return username; } async getSettings(): Promise { return [ { group: 'Pairing', title: "Manual Pairing Code", key: "pairingCode", readonly: true, value: this.pincode, }, { group: 'Pairing', title: "Camera QR Code", key: "qrCode", readonly: true, value: "The Pairing QR Code can be viewed in the 'Console'", }, { group: 'Pairing', title: "Username Override", value: this.getUsername(this.storage), key: "mac", }, { group: 'Network', title: 'Scrypted Server Address', value: this.storage.getItem('addressOverride'), key: 'addressOverride', description: 'Optional: The IP address used by the Scrypted server. Set this to the wired IP address to prevent usage of a wireless address.', choices: getAddresses(), placeholder: '192.168.2.100', combobox: true, }, { group: 'Network', title: 'Bridge Port', value: this.getHAPPort().toString(), key: 'portOverride', description: 'Optional: The TCP port used by the Scrypted bridge. If none is specified, a random port will be chosen.', type: 'number', }, { group: 'Network', title: 'mDNS Advertiser', description: 'Optional: Override the mDNS advertiser used to locate the Scrypted bridge', key: 'advertiserOverride', choices: [MDNSAdvertiser.BONJOUR, MDNSAdvertiser.CIAO], value: this.getAdvertiser(), }, ] } async putSetting(key: string, value: string | number | boolean): Promise { this.storage.setItem(key, value.toString()); if (key === 'portOverride' || key === 'advertiserOverride') { this.log.a('Reload the HomeKit plugin to apply this change.'); } } async start() { this.log.clearAlerts(); let defaultIncluded: any; try { defaultIncluded = JSON.parse(this.storage.getItem('defaultIncluded')); } catch (e) { defaultIncluded = {}; } const plugins = await systemManager.getComponent('plugins'); const accessoryIds = new Set(); for (const id of Object.keys(systemManager.getSystemState())) { const device = systemManager.getDeviceById(id); const supportedType = supportedTypes[device.type]; if (!supportedType?.probe(device)) continue; try { const mixins = (device.mixins || []).slice(); if (!mixins.includes(this.id)) { if (defaultIncluded[device.id] === includeToken) continue; mixins.push(this.id); await plugins.setMixins(device.id, mixins); defaultIncluded[device.id] = includeToken; } } catch (e) { console.error('error while checking device if syncable', e); this.log.a('Error while checking device if syncable. See Console.'); continue; } this.console.log('adding', device.name); const accessory = await supportedType.getAccessory(device, this); if (accessory) { accessoryIds.add(id); maybeAddBatteryService(device, accessory); const deviceInfo = device.info; if (deviceInfo) { const info = accessory.getService(Service.AccessoryInformation)!; if (deviceInfo.manufacturer) info.updateCharacteristic(Characteristic.Manufacturer, deviceInfo.manufacturer); if (deviceInfo.model) info.updateCharacteristic(Characteristic.Model, deviceInfo.model); if (deviceInfo.serialNumber) info.updateCharacteristic(Characteristic.SerialNumber, deviceInfo.serialNumber); if (deviceInfo.firmware) info.updateCharacteristic(Characteristic.FirmwareRevision, deviceInfo.firmware); if (deviceInfo.version) info.updateCharacteristic(Characteristic.HardwareRevision, deviceInfo.version); } const mixinStorage = deviceManager.getMixinStorage(device.id, this.nativeId); const standalone = mixinStorage.getItem('standalone') === 'true'; const standaloneCategory = typeToCategory(device.type); if (standalone && standaloneCategory) { this.standalones.set(device.id, accessory); let published = false; const publish = () => { published = true; accessory.publish({ username: this.getUsername(mixinStorage), port: 0, pincode: this.pincode, category: standaloneCategory, addIdentifyingMaterial: false, advertiser: this.getAdvertiser(), }); } const mixinConsole = deviceManager.getMixinConsole(device.id, this.nativeId); const maybeUnpublish = async () => { // wait a bit for things to settle before unpublishing sleep(5000); // maybe it was already unpublished due to a weird race condition. if (!published) return; // the online state may no longer be applicable (rebroadcast removed) if (!device.interfaces.includes(ScryptedInterface.Online)) return; mixinConsole.warn('Device is in accessory mode has gone offline. HomeKit services are being unpublished. ') published = false; // hack to allow republishing. accessory.controllerStorage = new ControllerStorage(accessory); accessory.unpublish(); } if (device.interfaces.includes(ScryptedInterface.Online)) { if (device.online) { publish(); } else { mixinConsole.warn('Device is in accessory mode and was offline during HomeKit startup. Device will not be started until it comes back online. Disable accessory mode if this is in error.'); } device.listen(ScryptedInterface.Online, () => { if (device.online && !published) publish(); else if (!device.online) maybeUnpublish(); }); } else { publish(); } } else { if (standalone) this.console.warn('Could not find standalone category mapping for accessory', device.name, device.type); this.bridge.addBridgedAccessory(accessory); } } } this.storage.setItem('defaultIncluded', JSON.stringify(defaultIncluded)); const username = this.getUsername(this.storage); const info = this.bridge.getService(Service.AccessoryInformation)!; info.updateCharacteristic(Characteristic.Manufacturer, "scrypted.app"); info.updateCharacteristic(Characteristic.Model, "scrypted"); info.updateCharacteristic(Characteristic.SerialNumber, username); info.updateCharacteristic(Characteristic.FirmwareRevision, packageJson.version); const publishInfo: PublishInfo = { username, port: this.getHAPPort(), pincode: this.pincode, category: Categories.BRIDGE, addIdentifyingMaterial: true, advertiser: this.getAdvertiser(), }; this.bridge.publish(publishInfo, true); const server: EventedHTTPServer = (this.bridge as any)._server.httpServer; server.on('connection-opened', connection => { connection.on('authenticated', () => { this.console.log('HomeKit Connection', connection.remoteAddress); this.homekitConnections.add(connection.remoteAddress); }) connection.on('closed', () => this.homekitConnections.delete(connection.remoteAddress)); }); qrcode.generate(this.bridge.setupURI(), { small: true }, (code: string) => { this.console.log('Pairing QR Code:') this.console.log('\n' + code); }); systemManager.listen(async (eventSource, eventDetails, eventData) => { if (eventDetails.eventInterface !== ScryptedInterface.ScryptedDevice) return; if (!eventDetails.changed) return; if (!eventDetails.property) return; if (eventDetails.property === ScryptedInterfaceProperty.id) return; const canMixin = await this.canMixin(eventSource.type, eventSource.interfaces); const includes = eventSource?.mixins?.includes(this.id); const has = accessoryIds.has(eventSource?.id); if (has && !canMixin) { this.console.log('restart event', eventSource?.id, eventDetails.property, eventData); this.log.a(`${eventSource.name} can no longer be synced. Reload the HomeKit plugin to apply these changes.`); return; } if (!has && includes) { this.console.log('restart event', eventSource?.id, eventDetails.property, eventData); this.log.a(`${eventSource.name} was added. HomeKit plugin will reload momentarily.`); deviceManager.requestRestart(); return; } }); } getAdvertiser() { const advertiser = this.storage.getItem('advertiserOverride'); switch (advertiser) { case MDNSAdvertiser.BONJOUR: return MDNSAdvertiser.BONJOUR; case MDNSAdvertiser.CIAO: return MDNSAdvertiser.CIAO; } // return os.platform() === 'win32' ? MDNSAdvertiser.CIAO : MDNSAdvertiser.BONJOUR; return MDNSAdvertiser.CIAO; } getHAPPort() { let port = parseInt(this.storage.getItem('portOverride')) || 0; if (!port) { port = Math.round(10000 + Math.random() * 30000); this.storage.setItem('portOverride', port.toString()); } return port; } async canMixin(type: ScryptedDeviceType, interfaces: string[]) { const supportedType = supportedTypes[type]; if (!supportedType?.probe({ interfaces, type, })) { return null; } const ret = [ ScryptedInterface.Settings, HOMEKIT_MIXIN, ]; return ret; } async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any }) { const options: SettingsMixinDeviceOptions = { mixinProviderNativeId: this.nativeId, mixinDeviceInterfaces, group: "HomeKit Settings", groupKey: "homekit", mixinDevice, mixinDeviceState, }; let ret: CameraMixin | HomekitMixin; if (canCameraMixin(mixinDeviceState.type, mixinDeviceInterfaces)) { ret = new CameraMixin(options); this.cameraMixins.set(ret.id, ret); } else { ret = new HomekitMixin(options); } if (ret.storageSettings.values.standalone) { setTimeout(() => { const accessory = this.standalones.get(mixinDeviceState.id); qrcode.generate(accessory.setupURI(), { small: true }, (code: string) => { ret.console.log('Pairing QR Code:') ret.console.log('\n' + code); }); }, 500); } return ret; } async releaseMixin(id: string, mixinDevice: any) { const device = systemManager.getDeviceById(id); if (device.mixins?.includes(this.id)) { return; } this.console.log('release mixin', id); this.log.a(`${device.name} was removed. The HomeKit plugin will reload momentarily.`); deviceManager.requestRestart(); } } export default new HomeKit();