diff --git a/src/platform.ts b/src/platform.ts index aa43cf9..1d78f2c 100755 --- a/src/platform.ts +++ b/src/platform.ts @@ -34,9 +34,12 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic; public readonly Accessory: typeof PlatformAccessory = this.api.platformAccessory; - // this is used to track restored cached accessories + // this is used to track restored Homebridge/Homekit representational versions of accessories from the cache public readonly accessories: PlatformAccessory[] = []; + // this is used to store an accessible reference to inialized accessories + public readonly konnectedPlatformAccessories = {}; + // define shared variables here public listenerIP = this.config.advanced.listenerIP || ip.address(); // system defined primary network interface public listenerPort: number = this.config.advanced.listenerPort || 0; // zero = autochoose @@ -59,7 +62,6 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { /** * This function is invoked when homebridge restores cached accessories from disk at startup. - * It should be used to setup event handlers for characteristics and update respective values. */ configureAccessory(accessory: PlatformAccessory) { this.log.info('Loading accessory from cache:', accessory.displayName); @@ -96,18 +98,61 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { const respond = (req, res) => { // console.log(JSON.stringify(ReplaceCircular(req), null, 4)); - // validate bearer auth token + // bearer auth token not provided + if (typeof req.headers.authorization === 'undefined') { + this.log.error(`Authentication failed for ${req.params.id}, token missing, with request body:`, req.body); + + // send the following response + res.status(401).json({ + success: false, + reason: 'Authorization failed, token missing', + }); + return; + } + + // validate provided bearer auth token if (this.listenerAuth.includes(req.headers.authorization.split('Bearer ').pop())) { + // send the following response res.status(200).json({ success: true }); - this.log.info(`Authentication successful for ${req.params.id}`); - this.log.info('Authentication token:', req.headers.authorization.split('Bearer ').pop()); - this.log.info(req.body); + this.log.debug(`Authentication successful for ${req.params.id}`); + this.log.debug('Authentication token:', req.headers.authorization.split('Bearer ').pop()); + + let deviceZone = ''; + if ('pin' in req.body) { + // convert a pin to a zone + Object.entries(ZONES_TO_PINS).map(([key, value]) => { + if (value === req.body.pin) { + deviceZone = key; + this.log.debug(req.body, `(zone: ${deviceZone})`); + } + }); + } else { + // use the zone + deviceZone = req.body.zone; + this.log.debug(req.body); + } + + const deviceUUID = this.api.hap.uuid.generate(req.params.id + '-' + deviceZone); + + const existingAccessory = this.accessories.find((accessory) => accessory.UUID === deviceUUID); + + // check if the accessory already exists + if (existingAccessory && existingAccessory.context.device.UUID === deviceUUID) { + this.log.info( + 'Received state change for', + `${existingAccessory.displayName} (${existingAccessory.UUID})` + ); + + // update accessory in the platform and Homekit + this.konnectedPlatformAccessories[existingAccessory.UUID].service.updateCharacteristic( + this.Characteristic.ContactSensorState, + req.body.state + ); + + } - // NEXT: - // call state change logic - // check to see if that id exists } else { // rediscover and reprovision panels if (this.ssdpDiscovering === false) { @@ -126,27 +171,15 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { // this.log.error(req.connection.remoteAddress); // this is consistent from the panel // this.log.error(req.connection.remotePort); // this is random from the panel - // NEXT: - // we need to reprovision the device with a new token - // we need to get the timing of when the device finishes its retry attempts and reboots - // after it reboots, there's a window of opportunity to re-provision the device - - // PROBLEMS WITH REPROVISIONING: - // if we reprovision here, the reprovision task will run on each inbound request - // we need to make sure the reprovision is only called once for each device - // to allow the device to reboot with the new authentication creds, etc. } }; // listen for requests at the following route/endpoint - app - .route('/api/konnected/device/:id') + app.route('/api/konnected/device/:id') .put(respond) // Alarm Panel V1-V2 .post(respond); // Alarm Panel Pro } - - /** * Discovers alarm panels on the network. * https://help.konnected.io/support/solutions/articles/32000026805-discovery @@ -156,6 +189,9 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { * Alarm Panel Pro: urn:schemas-konnected-io:device:Security:2 */ discoverPanels() { + // first remove all accessories (this cleans out stale/accessories) + // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, this.accessories); + const ssdpClient = new client.Client(); const ssdpTimeout = (this.config.advanced?.discoveryTimeout || 10) * 1000; const ssdpUrnPartial = 'urn:schemas-konnected-io:device'; @@ -176,7 +212,6 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { // extract UUID of panel from the USN string const panelUUID: string = headers.USN!.match(/^uuid:(.*)::.*$/i)![1] || ''; - // console.log(ssdpHeaderUSN); // console.log(ssdpHeaderLocation); // console.log('headers:', headers); @@ -196,17 +231,25 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { // use the above information to construct panel in homebridge config this.addPanelToConfig(panelUUID, panelResponseObject); - // if the settings property does not exist in the response, then we have an unprovisioned panel + // if the settings property does not exist in the response, + // then we have an unprovisioned panel if (Object.keys(panelResponseObject.settings).length === 0) { this.provisionPanel(panelUUID, panelResponseObject, listenerObject); } else { - const panelBroadcastEndpoint = new URL(panelResponseObject.settings.endpoint); - // if the IP address or port are not the same, reprovision endpoint component - if ( - panelBroadcastEndpoint.host !== this.listenerIP || - Number(panelBroadcastEndpoint.port) !== this.listenerPort - ) { - this.provisionPanel(panelUUID, panelResponseObject, listenerObject); + if (panelResponseObject.settings.endpoint_type === 'rest') { + const panelBroadcastEndpoint = new URL(panelResponseObject.settings.endpoint); + + // if the IP address or port are not the same, reprovision endpoint component + if ( + panelBroadcastEndpoint.host !== this.listenerIP || + Number(panelBroadcastEndpoint.port) !== this.listenerPort + ) { + this.provisionPanel(panelUUID, panelResponseObject, listenerObject); + } + } else if (panelResponseObject.settings.endpoint_type === 'aws_iot') { + this.log.error( + `ERROR: Panel ${panelUUID} has already been provisioned to use the Konnected Cloud. Please submit a ticket to de-register your panel from the Konnected Cloud before provisioning it with Homebridge: https://help.konnected.io/support/tickets/new` + ); } } }); @@ -320,13 +363,17 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { // if there are panels in the plugin config if (typeof this.config.panels !== 'undefined') { + + // storage variable for the array of zones + const accessoriesArray: Record[] = []; + // loop through the available panels for (const configPanel of this.config.panels) { // isolate specific panel and make sure there are zones in that panel if (configPanel.uuid === panelUUID && configPanel.zones) { - // variable for checking multiple zones with the same zoneNumber assigned - // (if users don't use Config UI X to generate their config) - const zonesCheck: number[] = []; + // variable for deduping zones with the same zoneNumber + // (use-case: if users don't use Config UI X to generate their config) + const zonesCheck: string[] = []; configPanel.zones.forEach((configPanelZone) => { // create type interface for panelZone variable @@ -336,55 +383,79 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { } let panelZone: PanelZone; - // check if zoneNumber is a duplicate - if (!zonesCheck.includes(configPanelZone.zoneNumber)) { - // if not a duplicate, push it into the zoneCheck array - zonesCheck.push(configPanelZone.zoneNumber); - - // V1-V2 vs Pro detection - if ('model' in panelObject) { - // this is a Pro panel + // V1-V2 vs Pro detection + if ('model' in panelObject) { + // this is a Pro panel + panelZone = { + zone: configPanelZone.zoneNumber, + }; + } else { + // this is a V1-V2 panel + // convert zone to a pin + if (ZONES_TO_PINS[configPanelZone.zoneNumber]) { + const zonePin = ZONES_TO_PINS[configPanelZone.zoneNumber]; panelZone = { - zone: configPanelZone.zoneNumber, + pin: zonePin, }; } else { - // this is a V1-V2 panel - // convert zone to a pin - if (ZONES_TO_PINS[configPanelZone.zoneNumber]) { - const zonePin = ZONES_TO_PINS[configPanelZone.zoneNumber]; - panelZone = { - pin: zonePin, - }; - } else { - panelZone = {}; - this.log.warn( - `Invalid Zone: Cannot assign the zone number '${configPanelZone.zoneNumber}' for Konnected V1-V2 Alarm Panels.` - ); - } + panelZone = {}; + this.log.warn( + `Invalid Zone: Cannot assign the zone number '${configPanelZone.zoneNumber}' for Konnected V1-V2 Alarm Panels.` + ); } + } - if (ZONE_TYPES.sensors.includes(configPanelZone.zoneType)) { - sensors.push(panelZone); - } else if (ZONE_TYPES.dht_sensors.includes(configPanelZone.zoneType)) { - dht_sensors.push(panelZone); - } else if (ZONE_TYPES.ds18b20_sensors.includes(configPanelZone.zoneType)) { - ds18b20_sensors.push(panelZone); - } else if (ZONE_TYPES.actuators.includes(configPanelZone.zoneType)) { - actuators.push(panelZone); - } + // put panelZone into the correct device type for the panel + if (ZONE_TYPES.sensors.includes(configPanelZone.zoneType)) { + sensors.push(panelZone); + } else if (ZONE_TYPES.dht_sensors.includes(configPanelZone.zoneType)) { + dht_sensors.push(panelZone); + } else if (ZONE_TYPES.ds18b20_sensors.includes(configPanelZone.zoneType)) { + ds18b20_sensors.push(panelZone); + } else if (ZONE_TYPES.actuators.includes(configPanelZone.zoneType)) { + actuators.push(panelZone); + } + + // If there's a chip ID in the panelObject, use that, or use mac address. + // V1/V2 panels only have one interface (WiFi). Panels with chipID are Pro versions + // with two network interfaces (WiFi & Ethernet) with separate mac addresses. + // If one network interface goes down, the board can fallback to the other + // interface and the accessories lose their associated UUID, which can + // result in duplicated accessories, half of which become non-responsive. + const panelShortUUID: string = + 'chipId' in panelObject ? panelUUID.match(/([^-]+)$/i)![1] : panelObject.mac.replace(/:/g, ''); + + // genereate unique ID for zone + const zoneUUID = this.api.hap.uuid.generate(panelShortUUID + '-' + configPanelZone.zoneNumber); + + // if there's a model in the panelObject, that means the panel is Pro + const panelModel: string = 'model' in panelObject ? 'Pro' : 'V1-V2'; + + // dedupe zones with the same zoneNumber + if (!zonesCheck.includes(zoneUUID)) { + // if not a duplicate, push the zone's UUID into the zoneCheck array + zonesCheck.push(zoneUUID); + + const zoneObject = { + UUID: zoneUUID, + displayName: configPanelZone.zoneLocation + ' ' + ZONE_TYPES_TO_NAMES[configPanelZone.zoneType], + type: configPanelZone.zoneType, + model: panelModel + ' ' + ZONE_TYPES_TO_NAMES[configPanelZone.zoneType], + serialNumber: panelShortUUID + '-' + configPanelZone.zoneNumber, + }; + + console.log('device:', zoneObject); - // register the zone with homebridge/homekit - this.registerZoneAsAccessory(panelUUID, panelObject, { - zoneNumber: configPanelZone.zoneNumber, - zoneType: configPanelZone.zoneType, - zoneLocation: configPanelZone.zoneLocation, - }); + accessoriesArray.push(zoneObject); } else { this.log.warn( `Duplicate Zone: Zone number '${configPanelZone.zoneNumber}' is assigned in two or more zones, please check your homebridge configuration for panel with UUID ${panelUUID}.` ); } }); + + // register the zones as accessories in Homebridge and Homekit + this.registerZonesAsAccessories(accessoriesArray); } } } @@ -466,143 +537,40 @@ export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin { * @param panelObject PanelObjectInterface The status response object of the plugin from discovery. * @param panelZoneObject object Zone object with zone number and zone type. */ - registerZoneAsAccessory( - panelUUID: string, - panelObject: PanelObjectInterface, - panelZoneObject: { - zoneNumber: string; - zoneType: string; - zoneLocation: string; - } - ) { - const panelShortUUID: string = panelUUID.match(/([^-]+)$/i)![1]; - const panelModel = 'model' in panelObject ? 'Pro' : 'V1-V2'; - - const device = { - UUID: this.api.hap.uuid.generate(panelShortUUID + '-' + panelZoneObject.zoneNumber), - displayName: panelZoneObject.zoneLocation + ' ' + ZONE_TYPES_TO_NAMES[panelZoneObject.zoneType], - type: panelZoneObject.zoneType, - model: panelModel + ' ' + ZONE_TYPES_TO_NAMES[panelZoneObject.zoneType], - serialNumber: panelShortUUID + '-' + panelZoneObject.zoneNumber, - }; - - console.log(device); - - // this.log.info('this.accessories:', this.accessories); - - // see if an accessory with the same uuid has already been registered and restored from - // the cached devices we stored in the `configureAccessory` method above - const existingAccessory = this.accessories.find((accessory) => accessory.UUID === device.UUID); - - // check if the accessory already exists - if (existingAccessory && existingAccessory.context.device.UUID === device.UUID) { - this.log.info('Restoring existing accessory from cache:', `${existingAccessory.displayName} (${existingAccessory.UUID})`); - - existingAccessory.context.device = device; - - // create the accessory handler for the restored accessory - // this is imported from `platformAccessory.ts` - new KonnectedPlatformAccessory(this, existingAccessory); - - // update accessory in platform and homekit - this.api.updatePlatformAccessories([existingAccessory]); - - // otherwise if it doesn't exist - } else { - this.log.info('Adding new accessory:', `${device.displayName} (${device.UUID})`); - - // create a new accessory - const accessory = new this.api.platformAccessory(device.displayName, device.UUID); - - // store a copy of the device object in the platform accessory - accessory.context.device = device; - - // create the accessory handler for the newly create accessory - // this is imported from `platformAccessory.ts` - new KonnectedPlatformAccessory(this, accessory); - - // link accessory to your platform and homekit - this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM, [accessory]); - } - } - - /** - * This method removes panel zones as accessories in homebridge & homekit. - * - * @param panelUUID string UUID for the panel as reported in the USN on discovery. - * @param panelObject PanelObjectInterface The status response object of the plugin from discovery. - * @param panelZoneObject object Zone object with zone number and zone type. - */ - unRegisterZoneAsAccessory( - panelUUID: string, - panelObject: PanelObjectInterface, - panelZoneObject: { - zoneNumber: string; - zoneType: string; - zoneLocation: string; - } - ) { - console.log('panelUUID:', panelUUID); - console.log('panelObject:', panelObject); - console.log('panelZoneObject:', panelZoneObject); - - const panelShortUUID: string = panelUUID.match(/([^-]+)$/i)![1]; - const panelModel = 'model' in panelObject ? panelObject.model : 'Konnected V1-V2'; - - const device = { - // UUID: this.api.hap.uuid.generate(panelShortUUID + '-' + panelZoneObject.zoneNumber) - UUID: panelShortUUID + '-' + panelZoneObject.zoneNumber, - displayName: panelZoneObject.zoneLocation + ' ' + ZONE_TYPES_TO_NAMES[panelZoneObject.zoneType], - model: panelModel + ' ' + ZONE_TYPES_TO_NAMES[panelZoneObject.zoneType], - }; - - console.log(device); + registerZonesAsAccessories(accessoriesArray) { - // this.log.info('this.accessories:', this.accessories); + // here we loop through the passed in array of zones and register them as accessories + accessoriesArray.forEach((panelZoneObject) => { + // see if an accessory with the same uuid has already been registered and restored from + // the cached devices we stored in the `configureAccessory` method above + const existingAccessory = this.accessories.find((accessory) => accessory.UUID === panelZoneObject.UUID); - // see if an accessory with the same uuid has already been registered and restored from - // the cached devices we stored in the `configureAccessory` method above - const existingAccessory = this.accessories.find((accessory) => accessory.UUID === device.UUID); + // check if the accessory already exists + if (existingAccessory && existingAccessory.context.device.UUID === panelZoneObject.UUID) { + this.log.info('Restoring existing accessory from cache:', `${existingAccessory.displayName} (${existingAccessory.UUID})`); - this.log.info('existingAccessory?.context.device.UUID:', existingAccessory?.context.device.UUID); + // update zone object in the platform accessory cache + existingAccessory.context.device = panelZoneObject; - // check if the accessory already exists - if (existingAccessory && existingAccessory.context.device.exampleUniqueId === device.UUID) { - this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName); - - existingAccessory.context.device = device; - - // create the accessory handler for the restored accessory - // this is imported from `platformAccessory.ts` - // new KonnectedPlatformAccessory(this, existingAccessory); - - // update accessory in platform and homekit - // this.api.updatePlatformAccessories([existingAccessory]); - - // otherwise if it doesn't exist - } else if (existingAccessory && existingAccessory.context.device.exampleUniqueId !== device.UUID) { - this.log.info('Removing accessory from cache:', existingAccessory.displayName); - - // remove accessory from platform and homekit - // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM, [existingAccessory]); + // store a direct reference to the initialized accessory in the KonnectedPlatformAccessories object + this.konnectedPlatformAccessories[panelZoneObject.UUID] = new KonnectedPlatformAccessory(this, existingAccessory); + } else { + this.log.info('Adding new accessory:', `${panelZoneObject.displayName} (${panelZoneObject.UUID})`); - // otherwise if it doesn't exist - } else { - this.log.info('Adding new accessory:', device.UUID); + // create a new accessory + const accessory = new this.api.platformAccessory(panelZoneObject.displayName, panelZoneObject.UUID); - // create a new accessory - const accessory = new this.api.platformAccessory(device.displayName, device.UUID); + // store zone object in the platform accessory cache + accessory.context.device = panelZoneObject; - // store a copy of the device object in the platform accessory - accessory.context.device = device; + // store a direct reference to the initialized accessory in the KonnectedPlatformAccessories object + this.konnectedPlatformAccessories[panelZoneObject.UUID] = new KonnectedPlatformAccessory(this, accessory); - // create the accessory handler for the newly create accessory - // this is imported from `platformAccessory.ts` - // new KonnectedPlatformAccessory(this, accessory); + // link accessory to your platform and homekit + this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); + } + }); - // link accessory to your platform and homekit - // this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM, [accessory]); - } } /** diff --git a/src/platformAccessory.ts b/src/platformAccessory.ts index de2fe30..3d373c7 100755 --- a/src/platformAccessory.ts +++ b/src/platformAccessory.ts @@ -5,79 +5,61 @@ import { KonnectedHomebridgePlatform } from './platform'; /** * Platform Accessory - * An instance of this class is created for each accessory your platform registers - * Each accessory may expose multiple services of different service types. + * An instance of this class is created for each accessory registered */ export class KonnectedPlatformAccessory { private service: Service; - /** - * These are just used to create a working example - * You should implement your own code to track the state of your accessory - */ - private binarySensorState = { - ContactSensorState: 1, - } - constructor( private readonly platform: KonnectedHomebridgePlatform, - private readonly accessory: PlatformAccessory, + private readonly accessory: PlatformAccessory ) { // set accessory information - this.accessory.getService(this.platform.Service.AccessoryInformation)! - .setCharacteristic(this.platform.Characteristic.Manufacturer, PLATFORM_NAME) + this.accessory + .getService(this.platform.Service.AccessoryInformation)! + .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Konnected') .setCharacteristic(this.platform.Characteristic.Model, accessory.context.device.model) - .setCharacteristic(this.platform.Characteristic.SerialNumber, accessory.context.device.SerialNumber); + .setCharacteristic(this.platform.Characteristic.SerialNumber, accessory.context.device.serialNumber); // .setCharacteristic(this.platform.Characteristic.FirmwareRevision, accessory.context.device.FirmwareVersion) // .setCharacteristic(this.platform.Characteristic.HardwareRevision, accessory.context.device.HardwareRevision) + + + + // Logic here to determine what kind of accessory it is based on accessory.context.device.type referencing ZONE_TYPES_TO_ACCESSORIES + // for now we are just able to make them contact sensors + + + // get the device service if it exists, otherwise create a new device service - // you can create multiple services for each accessory - this.service = this.accessory.getService(this.platform.Service.ContactSensor) || this.accessory.addService(this.platform.Service.ContactSensor); + this.service = + this.accessory.getService(this.platform.Service.ContactSensor) || + this.accessory.addService(this.platform.Service.ContactSensor); // To avoid "Cannot add a Service with the same UUID another Service without also defining a unique 'subtype' property." error, // when creating multiple services of the same type, you need to use the following syntax to specify a name and subtype id: // this.accessory.getService('NAME') ?? this.accessory.addService(this.platform.Service.Lightbulb, 'NAME', 'USER_DEFINED_SUBTYPE'); - // set the service name, this is what is displayed as the default name on the Home app + // set the accessory's default name in the Home app this.service.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.displayName); - // each service must implement at-minimum the "required characteristics" for the given service type - // see https://developers.homebridge.io/#/service/Lightbulb - - // register handlers for the Open/Closed Characteristic - this.service.getCharacteristic(this.platform.Characteristic.ContactSensorState) - .on('get', this.getState.bind(this)); // GET - bind to the `getState` method below + // register handlers for the state, Homekit will call this periodically + this.service.getCharacteristic(this.platform.Characteristic.ContactSensorState).on('get', this.getState.bind(this)); // GET - bind to the `getState` method below - this.service.updateCharacteristic(this.platform.Characteristic.ContactSensorState, 1); + // this.service + // .getCharacteristic(this.platform.Characteristic.ContactSensorState) + // .updateValue(accessory.context.device.state); } /** * Handle the "GET" requests from HomeKit - * These are sent when HomeKit wants to know the current state of the accessory, for example, checking if a Light bulb is on. - * - * GET requests should return as fast as possbile. A long delay here will result in - * HomeKit being unresponsive and a bad user experience in general. - * - * If your device takes time to respond you should update the status of your device - * asynchronously instead using the `updateCharacteristic` method instead. - - * @example - * this.service.updateCharacteristic(this.platform.Characteristic.On, true) */ getState(callback: CharacteristicGetCallback) { - // implement your own code to check if the device is on - const isOpen = this.binarySensorState.ContactSensorState; + const state = 0; - this.platform.log.debug( - `Get [${this.accessory.context.device.displayName}] 'ContactSensorState' Characteristic -> ${isOpen}` - ); + this.platform.log.debug(`Get [${this.accessory.context.device.displayName}] 'ContactSensorState' Characteristic: ${state}`); - // you must call the callback function - // the first argument should be null if there were no errors - // the second argument should be the value to return - callback(null, isOpen); + callback(null, state); } - }