diff --git a/config.schema.json b/config.schema.json index 2effed3..f8d59cc 100644 --- a/config.schema.json +++ b/config.schema.json @@ -49,6 +49,20 @@ "required": false, "pattern": "^([A-F0-9]{2}:){5}[A-F0-9]{2}$", "placeholder": "00:00:00:00:00:00" + }, + "username": { + "title": "(Optional) Username", + "description": "Username to access the Native API", + "type": "string", + "required": false, + "placeholder": "abc123" + }, + "password": { + "title": "(Optional) Password", + "description": "Password to access the Native API", + "type": "string", + "required": false, + "placeholder": "xyz456" } } } diff --git a/package-lock.json b/package-lock.json index f295248..e26fae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "homebridge-blaq", - "version": "0.2.31", + "version": "0.2.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebridge-blaq", - "version": "0.2.31", + "version": "0.2.32", "funding": [ { "type": "github", diff --git a/package.json b/package.json index ed7ee65..de023a4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": false, "displayName": "Konnected BlaQ", "name": "homebridge-blaq", - "version": "0.2.31", + "version": "0.2.32", "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/base.ts b/src/accessory/base.ts index d63ff0b..f6f9562 100644 --- a/src/accessory/base.ts +++ b/src/accessory/base.ts @@ -1,7 +1,9 @@ import { CharacteristicValue, Logger, PlatformAccessory, Service, WithUUID } from 'homebridge'; +import fetch from 'node-fetch'; // I am, in fact, trying to make fetch happen. import { LogMessageEvent, PingMessageEvent, StateUpdateMessageEvent, StateUpdateRecord } from '../utils/eventsource'; import { BlaQHomebridgePluginPlatform } from '../platform'; import { BlaQTextSensorEvent } from '../types'; +import type { RequestInfo, RequestInit } from 'node-fetch'; export interface BaseBlaQAccessoryInterface { setAPIBaseURL: (apiBaseURL: string) => void; @@ -14,6 +16,8 @@ export interface BaseBlaQAccessoryInterface { export type BaseBlaQAccessoryConstructorParams = { accessory: PlatformAccessory; apiBaseURL: string; + apiUser?: string; + apiPass?: string; friendlyName: string; platform: BlaQHomebridgePluginPlatform; serialNumber: string; @@ -32,6 +36,8 @@ export const correctAPIBaseURL = (inputURL: string) => { export class BaseBlaQAccessory implements BaseBlaQAccessoryInterface { protected apiBaseURL: string; + protected user?: string; + protected pass?: string; protected firmwareVersion?: string; protected synced?: boolean; protected queuedEvents: { @@ -49,6 +55,8 @@ export class BaseBlaQAccessory implements BaseBlaQAccessoryInterface { constructor({ accessory, apiBaseURL, + apiUser, + apiPass, friendlyName, platform, serialNumber, @@ -60,6 +68,8 @@ export class BaseBlaQAccessory implements BaseBlaQAccessoryInterface { this.friendlyName = friendlyName; this.serialNumber = serialNumber; this.apiBaseURL = correctAPIBaseURL(apiBaseURL); + this.user = apiUser; + this.pass = apiPass; this.accessoryInformationService = this.getOrAddService(this.platform.service.AccessoryInformation); // set accessory information this.accessoryInformationService @@ -160,4 +170,16 @@ export class BaseBlaQAccessory implements BaseBlaQAccessoryInterface { setAPIBaseURL(url: string){ this.apiBaseURL = correctAPIBaseURL(url); } + + protected authFetch(url: URL | RequestInfo, init?: RequestInit){ + const newInit = init || {}; + const basicCreds = `${this.user}:${this.pass}`; + newInit['headers'] = { + ...newInit['headers'], + ...(this.user && this.pass ? { + 'Authorization': `Basic ${Buffer.from(basicCreds).toString('base64')}`, + } : {}), + }; + return fetch(url, newInit); + } } \ No newline at end of file diff --git a/src/accessory/garage-door.ts b/src/accessory/garage-door.ts index 977c53a..a3664ce 100644 --- a/src/accessory/garage-door.ts +++ b/src/accessory/garage-door.ts @@ -1,5 +1,4 @@ import { CharacteristicValue, Service } from 'homebridge'; -import fetch from 'node-fetch'; // I am, in fact, trying to make fetch happen. import { BlaQBinarySensorEvent, @@ -90,7 +89,7 @@ export class BlaQGarageDoorAccessory extends BaseBlaQAccessory { const apiTarget: string = lockDesired ? 'lock' : 'unlock'; const currentlyLocked = this.getLockState() === this.platform.characteristic.LockCurrentState.SECURED; if(lockDesired !== currentlyLocked){ - await fetch(`${this.apiBaseURL}/lock/${this.lockType}/${apiTarget}`, {method: 'POST'}); + await this.authFetch(`${this.apiBaseURL}/lock/${this.lockType}/${apiTarget}`, {method: 'POST'}); } } @@ -265,7 +264,7 @@ export class BlaQGarageDoorAccessory extends BaseBlaQAccessory { private async setHoldPositionState(target: CharacteristicValue){ const shouldHold = target; if(shouldHold){ - await fetch(`${this.apiBaseURL}/cover/${this.coverType}/stop`, {method: 'POST'}); + await this.authFetch(`${this.apiBaseURL}/cover/${this.coverType}/stop`, {method: 'POST'}); } } @@ -281,7 +280,7 @@ export class BlaQGarageDoorAccessory extends BaseBlaQAccessory { throw new Error(`Invalid target door state: ${target}`); } this.updateCurrentDoorState(); - await fetch(`${this.apiBaseURL}/cover/${this.coverType}/${apiTarget}`, {method: 'POST'}); + await this.authFetch(`${this.apiBaseURL}/cover/${this.coverType}/${apiTarget}`, {method: 'POST'}); } getTargetDoorPosition(): CharacteristicValue { @@ -313,7 +312,7 @@ export class BlaQGarageDoorAccessory extends BaseBlaQAccessory { } this.updateCurrentDoorState(); if(this.position !== roundedTarget){ - await fetch(`${this.apiBaseURL}/cover/${this.coverType}/set?position=${roundedTarget / 100}`, {method: 'POST'}); + await this.authFetch(`${this.apiBaseURL}/cover/${this.coverType}/set?position=${roundedTarget / 100}`, {method: 'POST'}); } } diff --git a/src/accessory/garage-learn-mode.ts b/src/accessory/garage-learn-mode.ts index e5c99ca..2def75b 100644 --- a/src/accessory/garage-learn-mode.ts +++ b/src/accessory/garage-learn-mode.ts @@ -1,5 +1,4 @@ import { CharacteristicValue, Service } from 'homebridge'; -import fetch from 'node-fetch'; // I am, in fact, trying to make fetch happen. import { BlaQButtonEvent, @@ -47,7 +46,7 @@ export class BlaQGarageLearnModeAccessory extends BaseBlaQAccessory { 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'}); + await this.authFetch(`${this.apiBaseURL}/switch/learn/${apiTarget}`, {method: 'POST'}); } } diff --git a/src/accessory/garage-light.ts b/src/accessory/garage-light.ts index 2787019..1bf5d81 100644 --- a/src/accessory/garage-light.ts +++ b/src/accessory/garage-light.ts @@ -1,5 +1,4 @@ import { CharacteristicValue, Service } from 'homebridge'; -import fetch from 'node-fetch'; // I am, in fact, trying to make fetch happen. import { BlaQButtonEvent, @@ -52,7 +51,7 @@ export class BlaQGarageLightAccessory extends BaseBlaQAccessory { private async changePowerState(target: CharacteristicValue){ const apiTarget: string = target ? 'turn_on' : 'turn_off'; if(target !== this.isOn){ - await fetch(`${this.apiBaseURL}/light/${this.lightType}/${apiTarget}`, {method: 'POST'}); + await this.authFetch(`${this.apiBaseURL}/light/${this.lightType}/${apiTarget}`, {method: 'POST'}); } } diff --git a/src/accessory/garage-lock.ts b/src/accessory/garage-lock.ts index 6954dfa..3aa6197 100644 --- a/src/accessory/garage-lock.ts +++ b/src/accessory/garage-lock.ts @@ -1,5 +1,4 @@ import { CharacteristicValue, Service } from 'homebridge'; -import fetch from 'node-fetch'; // I am, in fact, trying to make fetch happen. import { BlaQButtonEvent, @@ -65,7 +64,7 @@ export class BlaQGarageLockAccessory extends BaseBlaQAccessory { const lockDesired = target === this.platform.characteristic.LockTargetState.SECURED; const apiTarget: string = lockDesired ? 'lock' : 'unlock'; if(lockDesired !== this.isLocked){ - await fetch(`${this.apiBaseURL}/lock/${this.lockType}/${apiTarget}`, {method: 'POST'}); + await this.authFetch(`${this.apiBaseURL}/lock/${this.lockType}/${apiTarget}`, {method: 'POST'}); } } diff --git a/src/accessory/garage-pre-close-warning.ts b/src/accessory/garage-pre-close-warning.ts index d505d95..7a5cdaf 100644 --- a/src/accessory/garage-pre-close-warning.ts +++ b/src/accessory/garage-pre-close-warning.ts @@ -1,5 +1,4 @@ import { CharacteristicValue, Service } from 'homebridge'; -import fetch from 'node-fetch'; // I am, in fact, trying to make fetch happen. import { BlaQButtonEvent, @@ -46,7 +45,7 @@ export class BlaQGaragePreCloseWarningAccessory extends BaseBlaQAccessory { private async changeIsOn(target: CharacteristicValue){ if(target && target !== this.isOn){ // only call the API when target = true (button on) - await fetch(`${this.apiBaseURL}/button/pre-close_warning/press`, {method: 'POST'}); + await this.authFetch(`${this.apiBaseURL}/button/pre-close_warning/press`, {method: 'POST'}); } } diff --git a/src/hub.ts b/src/hub.ts index 21de0de..e1d22d9 100644 --- a/src/hub.ts +++ b/src/hub.ts @@ -45,6 +45,8 @@ export class BlaQHub { private friendlyName?: string; private deviceMac?: string; private port: number; + private user?: string; + private pass?: string; private eventsBeforeAccessoryInit: { type: 'state' | 'log' | 'ping'; event: StateUpdateMessageEvent | LogMessageEvent | PingMessageEvent; @@ -62,6 +64,8 @@ export class BlaQHub { logger.debug('Initializing BlaQHub...'); this.host = configDevice.host; this.port = configDevice.port; + this.user = configDevice.username; + this.pass = configDevice.password; this.initAccessoryCallback = initAccessoryCallback; this.logger = logger; this.reinitializeEventSource(); @@ -85,6 +89,8 @@ export class BlaQHub { this.eventSource = new AutoReconnectingEventSource({ host: this.host, port: this.port, + user: this.user, + pass: this.pass, logger: this.logger, onStateUpdate: (stateEvent) => this.handleStateUpdate(stateEvent), onLog: (logEvent) => this.handleLogUpdate(logEvent), @@ -182,6 +188,8 @@ export class BlaQHub { private initGarageDoorAccessory({ platform, accessory, friendlyName, serialNumber}: InitAccessoryParams){ this.accessories.push(new BlaQGarageDoorAccessory({ platform, accessory, friendlyName, serialNumber, apiBaseURL: this.getAPIBaseURL(), type: this.pluginConfig.garageDoorType, + apiUser: this.user, + apiPass: this.pass, })); } @@ -189,6 +197,8 @@ export class BlaQHub { if(this.pluginConfig.enableLight ?? true) { this.accessories.push(new BlaQGarageLightAccessory({ platform, accessory, friendlyName, serialNumber, apiBaseURL: this.getAPIBaseURL(), + apiUser: this.user, + apiPass: this.pass, })); } } @@ -197,6 +207,8 @@ export class BlaQHub { if(this.pluginConfig.enableLockRemotes ?? true){ this.accessories.push(new BlaQGarageLockAccessory({ platform, accessory, friendlyName, serialNumber, apiBaseURL: this.getAPIBaseURL(), + apiUser: this.user, + apiPass: this.pass, })); } } @@ -205,6 +217,8 @@ export class BlaQHub { if(this.pluginConfig.enableMotionSensor ?? true){ this.accessories.push(new BlaQGarageMotionSensorAccessory({ platform, accessory, friendlyName, serialNumber, apiBaseURL: this.getAPIBaseURL(), + apiUser: this.user, + apiPass: this.pass, })); } } @@ -213,6 +227,8 @@ export class BlaQHub { if(this.pluginConfig.enablePreCloseWarning ?? true){ this.accessories.push(new BlaQGaragePreCloseWarningAccessory({ platform, accessory, friendlyName, serialNumber, apiBaseURL: this.getAPIBaseURL(), + apiUser: this.user, + apiPass: this.pass, })); } } @@ -221,6 +237,8 @@ export class BlaQHub { if(this.pluginConfig.enableLearnMode ?? true){ this.accessories.push(new BlaQGarageLearnModeAccessory({ platform, accessory, friendlyName, serialNumber, apiBaseURL: this.getAPIBaseURL(), + apiUser: this.user, + apiPass: this.pass, })); } } @@ -229,6 +247,8 @@ export class BlaQHub { if(this.pluginConfig.enableSeparateObstructionSensor ?? true){ this.accessories.push(new BlaQGarageObstructionSensorAccessory({ platform, accessory, friendlyName, serialNumber, apiBaseURL: this.getAPIBaseURL(), + apiUser: this.user, + apiPass: this.pass, })); } } diff --git a/src/platform.ts b/src/platform.ts index d2c0e47..b4a0302 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -6,6 +6,16 @@ import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'; import { ConfigDevice } from './types.js'; import { formatMAC } from './utils/formatters.js'; +const maskPassword = (d: ConfigDevice) => { + if(d.password){ + return { + ...d, + password: '***', + }; + } + return d; +}; + /** * HomebridgePlatform * This class is the main constructor for your plugin, this is where you should @@ -77,6 +87,21 @@ export class BlaQHomebridgePluginPlatform implements DynamicPlatformPlugin { } } + possiblyMergeWithManualConfigDevice(deviceToMerge: ConfigDevice){ + let manualConfigDevice = {}; + for (const configDevice of this.config.devices as ConfigDevice[]) { + const matchingMAC = configDevice.mac && formatMAC(configDevice.mac) === formatMAC(deviceToMerge.mac); + const matchingHost = configDevice.host && configDevice.host.toLowerCase() === deviceToMerge.host.toLowerCase(); + if(matchingMAC || matchingHost){ + manualConfigDevice = configDevice; + } + } + return { + ...manualConfigDevice, + ...deviceToMerge, + }; + } + searchBonjour(){ this.bonjourInstance.find({ type: 'konnected', @@ -89,13 +114,13 @@ export class BlaQHomebridgePluginPlatform implements DynamicPlatformPlugin { service.txt?.project_name?.toLowerCase()?.includes('garage') || service.txt?.project_name?.toLowerCase()?.includes('gdo'); if(service.txt?.web_api === 'true' && isGarageProject){ - const configEntry: ConfigDevice = { + const configEntry: ConfigDevice = this.possiblyMergeWithManualConfigDevice({ host: service.addresses?.[0] || service.host, port: service.port, displayName: service.txt?.friendly_name, mac: formatMAC(service.txt?.mac), - }; - this.logger.debug(`Discovered device via mDNS: ${JSON.stringify(configEntry)}`); + }); + this.logger.debug(`Discovered device via mDNS: ${JSON.stringify(maskPassword(configEntry))}`); this.possiblyRegisterNewDevice(configEntry); } }); @@ -110,7 +135,7 @@ export class BlaQHomebridgePluginPlatform implements DynamicPlatformPlugin { const FIVE_MINUTES_IN_MS = 5 * 60 * 1000; setInterval(() => this.searchBonjour(), FIVE_MINUTES_IN_MS); for (const configDevice of this.config.devices as ConfigDevice[]) { - this.logger.debug(`Discovered device via manual config: ${JSON.stringify(configDevice)}`); + this.logger.debug(`Discovered device via manual config: ${JSON.stringify(maskPassword(configDevice))}`); this.possiblyRegisterNewDevice(configDevice); } } diff --git a/src/types.ts b/src/types.ts index fee4d80..64cd2ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,9 @@ export type ConfigDevice = { displayName: string; host: string; port: number; - mac ? : string; + mac?: string; + username?: string; + password?: string; }; export type GarageLockType = 'lock' | 'lock_remotes'; export type GarageLightType = 'garage_light' | 'light'; diff --git a/src/utils/eventsource.ts b/src/utils/eventsource.ts index 4f60dd8..2bfd123 100644 --- a/src/utils/eventsource.ts +++ b/src/utils/eventsource.ts @@ -15,6 +15,8 @@ type AutoReconnectingEventSourceParams = { protocol?: 'http' | 'https'; host: string; port?: number; + user?: string; + pass?: string; path?: string; logger: Logger; onLog?: OnLogCallback; @@ -30,6 +32,8 @@ export class AutoReconnectingEventSource { private protocol: 'http' | 'https'; private host: string; private port: number; + private user?: string; + private pass?: string; private path: string; private logger: Logger; private onLog: OnLogCallback; @@ -43,6 +47,8 @@ export class AutoReconnectingEventSource { protocol = 'http', host, port = 80, + user, + pass, path = 'events', maxIdleBeforeReconnect = ONE_MINUTE_IN_MS, logger, @@ -59,6 +65,8 @@ export class AutoReconnectingEventSource { this.protocol = correctedProtocol as 'http' | 'https'; this.host = host; this.port = port; + this.user = user; + this.pass = pass; const correctedPath = path.startsWith('/') ? path.slice(1) : path; this.path = correctedPath; this.onLog = onLog; @@ -71,12 +79,24 @@ export class AutoReconnectingEventSource { private connectEventSource(){ if(!this.eventSource){ - this.eventSource = new EventSource(`${this.protocol}://${this.host}:${this.port}/${this.path}`); + const basicCreds = `${this.user}:${this.pass}`; + const eventSourceOptions = { + headers: { + ...(this.user && this.pass ? { + 'Authorization': `Basic ${Buffer.from(basicCreds).toString('base64')}`, + } : {}), + }, + }; + this.eventSource = new EventSource(`${this.protocol}://${this.host}:${this.port}/${this.path}`, eventSourceOptions); this.eventSource.addEventListener('error', error => { this.logger.error('EventSource got error', error); this.logger.error('Reinitializing EventSource...'); this.close(); - this.connectEventSource(); + if(error.status === 401){ + this.logger.error('Please configure valid credentials for this device!'); + }else{ + this.connectEventSource(); + } }); this.eventSource.addEventListener('log', log => { this.lastEventSourceEventDate = new Date();