diff --git a/package-lock.json b/package-lock.json index 5c7d866..4309a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "homebridge-blaq", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebridge-blaq", - "version": "0.2.5", + "version": "0.2.6", "license": "Apache-2.0", "dependencies": { "bonjour-service": "^1.2.1", diff --git a/package.json b/package.json index 7bb5180..39417b5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": false, "displayName": "Konnected BlaQ", "name": "homebridge-blaq", - "version": "0.2.5", + "version": "0.2.6", "description": "Control and view your garage door(s) remotely with real-time updates using Konnected's BlaQ hardware", "license": "Apache-2.0", "type": "module", diff --git a/src/accessory/garage-learn-mode.ts b/src/accessory/garage-learn-mode.ts new file mode 100644 index 0000000..79e9d0b --- /dev/null +++ b/src/accessory/garage-learn-mode.ts @@ -0,0 +1,180 @@ +import { CharacteristicValue, Logger, PlatformAccessory, Service } from 'homebridge'; +import fetch from 'node-fetch'; // I am, in fact, trying to make fetch happen. + +import { BlaQHomebridgePluginPlatform } from '../platform.js'; +import { + BlaQButtonEvent, + BlaQTextSensorEvent, +} from '../types.js'; +import { LogMessageEvent, StateUpdateMessageEvent, StateUpdateRecord } from '../utils/eventsource.js'; +import { BaseBlaQAccessory } from './base.js'; + +const correctAPIBaseURL = (inputURL: string) => { + let correctedAPIBaseURL = inputURL; + if(!correctedAPIBaseURL.includes('://')){ + correctedAPIBaseURL = `http://${correctedAPIBaseURL}`; + } + if(correctedAPIBaseURL.endsWith('/')){ + correctedAPIBaseURL = correctedAPIBaseURL.slice(0, -1); + } + return correctedAPIBaseURL; +}; + +type BlaQGarageLearnModeAccessoryConstructorParams = { + platform: BlaQHomebridgePluginPlatform; + accessory: PlatformAccessory; + model: string; + serialNumber: string; + apiBaseURL: string; +}; + +/** + * 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. + */ +export class BlaQGarageLearnModeAccessory implements BaseBlaQAccessory { + private logger: Logger; + private accessoryInformationService: Service; + private switchService: Service; + private apiBaseURL: string; + private firmwareVersion?: string; + private isOn?: boolean; + private readonly platform: BlaQHomebridgePluginPlatform; + private readonly accessory: PlatformAccessory; + private readonly model: string; + private readonly serialNumber: string; + + constructor({ + platform, + accessory, + model, + serialNumber, + apiBaseURL, + }: BlaQGarageLearnModeAccessoryConstructorParams) { + this.platform = platform; + this.logger = this.platform.logger; + this.logger.debug('Initializing BlaQGarageLearnModeAccessory...'); + this.accessory = accessory; + this.model = model; + this.serialNumber = serialNumber; + this.apiBaseURL = correctAPIBaseURL(apiBaseURL); + this.switchService = this.accessory.getService(this.platform.service.Switch) + || this.accessory.addService(this.platform.service.Switch); + + this.accessoryInformationService = this.accessory.getService(this.platform.service.AccessoryInformation) + || this.accessory.addService(this.platform.service.AccessoryInformation); + + // set accessory information + this.accessoryInformationService + .setCharacteristic(this.platform.characteristic.Manufacturer, 'Konnected') + .setCharacteristic(this.platform.characteristic.Model, this.model) + .setCharacteristic(this.platform.characteristic.SerialNumber, this.serialNumber); + + // Set the service name. This is what is displayed as the name on the Home + // app. We use what we stored in `accessory.context` in `discoverDevices`. + this.switchService.setCharacteristic(this.platform.characteristic.Name, accessory.context.device.displayName); + + this.switchService.getCharacteristic(this.platform.characteristic.On) + .onGet(this.getIsOn.bind(this)) + .onSet(this.changeIsOn.bind(this)); + + // Publish firmware version; this may not be initialized yet, so we set a getter. + // Note that this is against the AccessoryInformation service, not the GDO service. + this.accessoryInformationService + .getCharacteristic(this.platform.characteristic.FirmwareRevision) + .onGet(this.getFirmwareVersion.bind(this)); + this.logger.debug('Initialized BlaQGarageLearnModeAccessory!'); + } + + getFirmwareVersion(): CharacteristicValue { + return this.firmwareVersion || ''; + } + + private setFirmwareVersion(version: string) { + this.firmwareVersion = version; + this.accessoryInformationService.setCharacteristic( + this.platform.characteristic.FirmwareRevision, + version, + ); + } + + getIsOn(): CharacteristicValue { + return this.isOn || false; + } + + setIsOn(isOn: boolean) { + this.isOn = isOn; + this.switchService.setCharacteristic( + this.platform.characteristic.On, + this.isOn, + ); + } + + private async changeIsOn(target: CharacteristicValue){ + const apiTarget: string = target ? 'turn_on' : 'turn_off'; + if(target !== this.isOn){ // only call the API when target = true (button on) + await fetch(`${this.apiBaseURL}/switch/learn/${apiTarget}`, {method: 'POST'}); + } + } + + setAPIBaseURL(url: string){ + this.apiBaseURL = correctAPIBaseURL(url); + } + + handleStateEvent(stateEvent: StateUpdateMessageEvent){ + this.logger.debug('Processing state event:', stateEvent.data); + try { + const stateInfo = JSON.parse(stateEvent.data) as StateUpdateRecord; + if (['switch-learn'].includes(stateInfo.id)) { + const buttonEvent = stateInfo as BlaQButtonEvent & { state: 'ON' | 'OFF' }; + if(['OFF', 'ON'].includes(buttonEvent.state.toUpperCase())){ + this.setIsOn(buttonEvent.state.toUpperCase() === 'ON'); + } + } else if (['text_sensor-esphome_version', 'text_sensor-firmware_version'].includes(stateInfo.id)) { + const b = stateInfo as BlaQTextSensorEvent; + if (b.value === b.state && b.value !== '' && b.value !== null && b.value !== undefined) { + this.logger.info('Firmware version:', b.value); + this.setFirmwareVersion(b.value); + } else { + this.logger.error('Mismatched firmware versions in value/state:', b.value, b.state); + this.firmwareVersion = undefined; + } + } + } catch(e) { + this.logger.error('Cannot deserialize message:', stateEvent); + this.logger.error('Deserialization yielded:', e); + } + } + + handleLogEvent(logEvent: LogMessageEvent){ + this.logger.debug('BlaQ log:', logEvent.data); + try { + const logStr = logEvent.data; + const lowercaseLogStr = logStr.toLowerCase(); + const learnTurningOn = + lowercaseLogStr.includes('learn') && + lowercaseLogStr.includes('turning') && + lowercaseLogStr.includes('on'); + const learnStateOn = + lowercaseLogStr.includes('learn') && + lowercaseLogStr.includes('state') && + lowercaseLogStr.includes('on'); + const learnTurningOff = + lowercaseLogStr.includes('learn') && + lowercaseLogStr.includes('turning') && + lowercaseLogStr.includes('off'); + const learnStateOff = + lowercaseLogStr.includes('learn') && + lowercaseLogStr.includes('state') && + lowercaseLogStr.includes('off'); + if (learnTurningOn || learnStateOn) { + this.setIsOn(true); + } else if (learnTurningOff || learnStateOff) { + this.setIsOn(false); + } + } catch(e) { + this.logger.error('Log parsing error:', e); + } + } +} diff --git a/src/hub.ts b/src/hub.ts index eb78e79..71c0b56 100644 --- a/src/hub.ts +++ b/src/hub.ts @@ -8,6 +8,7 @@ import { BlaQGarageLightAccessory } from './accessory/garage-light.js'; import { BlaQGarageLockAccessory } from './accessory/garage-lock.js'; import { BlaQGarageMotionSensorAccessory } from './accessory/garage-motion-sensor.js'; import { BlaQGaragePreCloseWarningAccessory } from './accessory/garage-pre-close-warning.js'; +import { BlaQGarageLearnModeAccessory } from './accessory/garage-learn-mode.js'; interface BlaQPingEvent { title: string; @@ -157,12 +158,27 @@ export class BlaQHub { })); } + private initGarageLearnModeAccessory({ model, serialNumber }: ModelAndSerialNumber){ + const accessorySuffix = 'learn-mode'; + const nonMainAccessoryMACAddress = this.configDevice.mac && `${this.configDevice.mac}-${accessorySuffix}`; + const nonMainAccessorySerialNumber = `${serialNumber}-${accessorySuffix}`; + const {platform, accessory} = this.initAccessoryCallback( + { ...this.configDevice, mac: nonMainAccessoryMACAddress }, + model, + nonMainAccessorySerialNumber, + ); + this.accessories.push(new BlaQGarageLearnModeAccessory({ + platform, accessory, model, serialNumber, apiBaseURL: this.getAPIBaseURL(), + })); + } + private initAccessories({ model, serialNumber }: ModelAndSerialNumber){ this.initGarageDoorAccessory({ model, serialNumber }); this.initGarageLightAccessory({ model, serialNumber }); this.initGarageLockAccessory({ model, serialNumber }); this.initGarageMotionSensorAccessory({ model, serialNumber }); this.initGaragePreCloseWarningAccessory({ model, serialNumber }); + this.initGarageLearnModeAccessory({ model, serialNumber }); } private handlePingUpdate(msg: PingMessageEvent){