diff --git a/config.schema.json b/config.schema.json index 6fd23bb..05cd41b 100644 --- a/config.schema.json +++ b/config.schema.json @@ -5,10 +5,43 @@ "schema": { "type": "object", "properties": { + "auth": { + "title": "Authentication Mode", + "type": "string", + "default": "api-key", + "oneOf": [ + { "title": "API-Key", "enum": ["api-key"] }, + { "title": "Basic Auth", "enum": ["basic"] } + ], + "description": "The authentication method GlueHome plugin will use to access your Glue profile and Glue devices. API-Key auth is recommended.", + "required": true + }, "apiKey": { "title": "apiKey", "type": "string", - "required": true + "required": false, + "description": "The API-KEY issued using your Glue account.", + "condition": { + "functionBody": "return model.auth === 'api-key';" + } + }, + "username": { + "title": "username", + "type": "string", + "required": false, + "description": "You Glue account phone number in international format (e.g. +46734567890)", + "condition": { + "functionBody": "return model.auth === 'basic';" + } + }, + "password": { + "title": "password", + "type": "string", + "required": false, + "description": "Your Glue account password", + "condition": { + "functionBody": "return model.auth === 'basic';" + } } } } diff --git a/package-lock.json b/package-lock.json index 15ec4b7..0e5d56c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@gluehome/homebridge-gluehome", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b0f5307..cb1aa6c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "displayName": "GlueHome Homebridge Plugin", "name": "@gluehome/homebridge-gluehome", - "version": "0.1.3", + "version": "0.2.0", "description": "Homebridge plugin to integrate with GlueHome ecosystem.", "author": { "name": "GlueHome" diff --git a/src/api/client.ts b/src/api/client.ts index 5fb16b6..f31a140 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,44 +1,93 @@ -import axios, { AxiosInstance } from "axios" -import { Lock, LockOperation, CreateLockOperation } from "./" +import axios, { AxiosError, AxiosInstance } from 'axios'; +import { Lock, LockOperation, CreateLockOperation } from './'; +import { PLATFORM_NAME, VERSION, OS_VERSION } from '../settings'; + +const API_URL = 'https://user-api.gluehome.com'; +const USER_AGENT = `${PLATFORM_NAME}/${VERSION} (${OS_VERSION})`; + +export async function issueApiKey(username: string, password: string): Promise { + try { + const response = await axios.post(`${API_URL}/v1/api-keys`, { + name: 'homebridge', + scopes: ['locks.write', 'locks.read', 'events.read'], + }, + { + headers: { + 'Contenty-Type': 'application/json', + 'User-Agent': USER_AGENT, + }, + auth: { + username: username, + password: password, + }, + }); + + return response.data.apiKey; + } catch(err) { + throw Error(err); + } +} + +export interface ApiError { + code: number; + detail: string; + correlationId: string; +} export class GlueApi { private readonly apiKey: string; private httpClient: AxiosInstance; constructor(apiKey: string) { - this.apiKey = apiKey; + this.apiKey = apiKey; + + this.httpClient = axios.create({ + baseURL: API_URL, + timeout: 60000, + }); - this.httpClient = axios.create({ - baseURL: "https://user-api.gluehome.com", - timeout: 60000 - }); + this.httpClient.interceptors.request.use(config => { + config.headers.authorization = `Api-Key ${apiKey}`; + config.headers['User-Agent'] = USER_AGENT; + return config; + }, (error: AxiosError) => { + return Promise.reject(error.toJSON()); + }); - this.httpClient.interceptors.request.use(config => { - config.headers.authorization = `Api-Key ${apiKey}` - return config; - }, (error) => { - return Promise.reject(error); - }); + this.httpClient.interceptors.response.use( + res => res, + err => { + if (err.response === undefined) { + return Promise.reject(err); + } + if (err.response.status === 401) { + return Promise.reject('Wrong authentication data provided. Please check the plugin configuration.'); + } + const {title, code, correlationId, detail} = err.response.data; + const msg = `${title} (code: ${code} correlationId: ${correlationId} details: ${detail})`; + return Promise.reject(msg); + }, + ); } public getLocks(): Promise { - return this.httpClient.get("/v1/locks") - .then(res => res.data?.map(Lock.fromJson) ?? []); + return this.httpClient.get('/v1/locks') + .then(res => res.data?.map(Lock.fromJson) ?? []); } public getLock(id: string): Promise { - return this.httpClient.get(`/v1/locks/${id}`) - .then(res => Lock.fromJson(res.data)); + return this.httpClient.get(`/v1/locks/${id}`) + .then(res => Lock.fromJson(res.data)); } public getLockOperation(id: string, operationId: string): Promise { - return this.httpClient.get(`/v1/locks/${id}/operations/${operationId}`) - .then(res => LockOperation.fromJson(res.data)); + return this.httpClient.get(`/v1/locks/${id}/operations/${operationId}`) + .then(res => LockOperation.fromJson(res.data)); } public createLockOperation(id: string, operation: CreateLockOperation): Promise { - return this.httpClient.post(`/v1/locks/${id}/operations`, operation) - .then(res => LockOperation.fromJson(res.data)); + return this.httpClient.post(`/v1/locks/${id}/operations`, operation) + .then(res => LockOperation.fromJson(res.data)); } -} \ No newline at end of file +} diff --git a/src/api/lock.ts b/src/api/lock.ts index f4f1219..0ee0b81 100644 --- a/src/api/lock.ts +++ b/src/api/lock.ts @@ -1,5 +1,5 @@ export class Lock { - constructor( + constructor( public id: string, public serialNumber: string, public description: string, @@ -7,83 +7,83 @@ export class Lock { public batteryStatus: number, public connectionStatus: LockConnecitionStatus, public lastLockEvent?: LockEvent) { - } + } - public getLockModel(): string { - return this.serialNumber.substring(0, 4); - } + public getLockModel(): string { + return this.serialNumber.substring(0, 4); + } - public isBatteryLow(): boolean { - return this.batteryStatus < 50; - } + public isBatteryLow(): boolean { + return this.batteryStatus < 50; + } - public static fromJson(json): Lock { - return new Lock( - json.id, - json.serialNumber, - json.description, - json.firmwareVersion, - json.batteryStatus, - json.connectionStatus, - json.lastLockEvent - ); - } + public static fromJson(json): Lock { + return new Lock( + json.id, + json.serialNumber, + json.description, + json.firmwareVersion, + json.batteryStatus, + json.connectionStatus, + json.lastLockEvent, + ); + } } export enum LockOperationType { - Lock = "lock", - Unlock = "unlock" -}; + Lock = 'lock', + Unlock = 'unlock' +} export enum LockOperationStatus { - Pending = "pending", - Completed = "completed", - Timeout = "timeout", - Failed = "failed", -}; + Pending = 'pending', + Completed = 'completed', + Timeout = 'timeout', + Failed = 'failed', +} export enum LockConnecitionStatus { - Offline = "offline", - Disconnected = "disconnected", - Connected = "connected", - Busy = "busy", -}; + Offline = 'offline', + Disconnected = 'disconnected', + Connected = 'connected', + Busy = 'busy', +} export interface CreateLockOperation { - type: LockOperationType + type: LockOperationType; } export class LockOperation { - constructor( + constructor( public id: string, public status: LockOperationStatus, public reason?: string, - ) { } + ) { } - public isFinished(): boolean { - return this.status !== 'pending'; - } + public isFinished(): boolean { + return this.status !== 'pending'; + } - public static fromJson(json): LockOperation { - return new LockOperation( - json.id, - json.status, - json.reason - ) - } + public static fromJson(json): LockOperation { + return new LockOperation( + json.id, + json.status, + json.reason, + ); + } } export type EventType = - "unknown" | - "localLock" | - "localUnlock" | - "remoteLock" | - "remoteUnlock" | - "pressAndGo" | - "manualUnlock" | - "manualLock" + 'unknown' | + 'localLock' | + 'localUnlock' | + 'remoteLock' | + 'remoteUnlock' | + 'pressAndGo' | + 'manualUnlock' | + 'manualLock'; interface LockEvent { - eventType: EventType - lastLockEventDate: Date + eventType: EventType; + lastLockEventDate: Date; } \ No newline at end of file diff --git a/src/lock.ts b/src/lock.ts index ad6db49..aa082be 100644 --- a/src/lock.ts +++ b/src/lock.ts @@ -13,6 +13,7 @@ import { retry } from './utils'; export class GlueLockAccessory { private lockMechanism: Service; private batteryService: Service; + private isBusy = false; private lockCurrentState: Record = { 'unknown': this.platform.Characteristic.LockCurrentState.UNKNOWN, @@ -71,7 +72,7 @@ export class GlueLockAccessory { this.batteryService.getCharacteristic(this.platform.Characteristic.ChargingState) .on('get', this.getBatteryChargingState.bind(this)); - this.refreshLockData(); + this.scheduleRefreshLockData(); } getLockCurrentState(callback: CharacteristicGetCallback) { @@ -92,40 +93,42 @@ export class GlueLockAccessory { this.platform.log.debug(`setLockTargetState setLockTargetState to ${value} for lock ${this.lock.description}`); const targetValue = value as number; const remoteOperationType = this.lockTargetStateMapper[targetValue]; + + if (this.isBusy) { + this.platform.log.info(`Lock ${this.lock.description} is currently busy. Please retry in a few seconds.`); + callback(null); + return; + } if (this.lock.connectionStatus === LockConnecitionStatus.Connected) { this.glueClient .createLockOperation(this.lock.id, { type: remoteOperationType }) .then(createdOperation => { this.platform.log.debug(`operation for lock ${this.lock.description}`, createdOperation); - - return createdOperation.isFinished() ? - createdOperation : - retry({ - times: 5, - interval: 2000, + this.isBusy = true; + + return (createdOperation.isFinished()) + ? createdOperation + : retry({ + times: 20, + interval: 1000, task: () => this.checkRemoteOperationStatus(createdOperation.id), }); }) .then(operation => { this.platform.log.info('Operation finished', operation); - - if (operation.status === LockOperationStatus.Completed) { - this.lock.lastLockEvent = { - eventType: this.targetToCurrentStateMapper[targetValue], - lastLockEventDate: new Date(), - }; - - this.lockMechanism.setCharacteristic(this.platform.Characteristic.LockCurrentState, this.computeLockCurrentState()); - - callback(null, targetValue); - } else { - callback(new Error(`Remote operation ${operation.status}.`)); + if (operation.status !== LockOperationStatus.Completed) { + throw new Error(`Remote operation ${operation.status}.`); } + callback(null); }) .catch(err => { this.platform.log.error(err); callback(err); + }) + .finally(() => { + this.isBusy = false; + this.refreshLockData(); }); } else { callback(new Error(`Lock ${this.lock.description} is not connected.`)); @@ -172,19 +175,26 @@ export class GlueLockAccessory { this.platform.Characteristic.LockCurrentState.UNKNOWN; } + private scheduleRefreshLockData() { + return setInterval(() => { + this.refreshLockData(); + }, 30000); + } + private refreshLockData() { - setInterval(() => { - this.glueClient - .getLock(this.lock.id) - .then(updatedLock => { - this.platform.log.debug(`Update lock ${updatedLock.description} characteristics.`, updatedLock); - this.lock = updatedLock; - - this.lockMechanism.updateCharacteristic(this.platform.Characteristic.Name, this.lock.description); - this.batteryService.updateCharacteristic(this.platform.Characteristic.BatteryLevel, this.lock.batteryStatus); - this.batteryService.updateCharacteristic(this.platform.Characteristic.StatusLowBattery, this.computeLockBatteryStatus()); - this.lockMechanism.updateCharacteristic(this.platform.Characteristic.LockCurrentState, this.computeLockCurrentState()); - }); - }, 10000); + if (this.isBusy) { + return; + } + this.glueClient + .getLock(this.lock.id) + .then(updatedLock => { + this.platform.log.debug(`Update lock ${updatedLock.description} characteristics.`, updatedLock); + this.lock = updatedLock; + + this.lockMechanism.updateCharacteristic(this.platform.Characteristic.Name, this.lock.description); + this.batteryService.updateCharacteristic(this.platform.Characteristic.BatteryLevel, this.lock.batteryStatus); + this.batteryService.updateCharacteristic(this.platform.Characteristic.StatusLowBattery, this.computeLockBatteryStatus()); + this.lockMechanism.updateCharacteristic(this.platform.Characteristic.LockCurrentState, this.computeLockCurrentState()); + }); } -} \ No newline at end of file +} diff --git a/src/platform.ts b/src/platform.ts index 9116c27..a4e3de1 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,11 +1,13 @@ import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge'; - import { PLATFORM_NAME, PLUGIN_NAME } from './settings'; import { GlueLockAccessory } from './lock'; import { GlueApi } from './api'; +import { issueApiKey } from './api/client'; interface GlueHomePlatformConfig extends PlatformConfig { apiKey: string; + username: string; + password: string; } export class GlueHomePlatformPlugin implements DynamicPlatformPlugin { @@ -13,23 +15,32 @@ export class GlueHomePlatformPlugin implements DynamicPlatformPlugin { public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic; public readonly accessories: PlatformAccessory[] = []; - private readonly apiClient: GlueApi; + private apiClient?: GlueApi; constructor( public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API, ) { - const glueConfig = config as GlueHomePlatformConfig; - - this.apiClient = new GlueApi(glueConfig.apiKey); - this.api.on('didFinishLaunching', () => { log.debug('Executed didFinishLaunching callback'); - this.discoverDevices(); + + this.getApiKey(config as GlueHomePlatformConfig) + .then(key => { + this.apiClient = new GlueApi(key); + this.discoverDevices(); + }).catch(err => { + log.error('Error authenticating:', err); + }); }); } + getApiKey(glueConfig: GlueHomePlatformConfig): Promise { + return (glueConfig.apiKey) + ? Promise.resolve(glueConfig.apiKey) + : issueApiKey(glueConfig.username, glueConfig.password); + } + configureAccessory(accessory: PlatformAccessory) { this.log.info('Loading accessory from cache:', accessory.displayName); @@ -37,6 +48,9 @@ export class GlueHomePlatformPlugin implements DynamicPlatformPlugin { } discoverDevices() { + if (this.apiClient === undefined) { + return; + } this.apiClient.getLocks() .then(locks => { for (const lock of locks) { @@ -46,7 +60,7 @@ export class GlueHomePlatformPlugin implements DynamicPlatformPlugin { if (lock) { this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName); - new GlueLockAccessory(this, existingAccessory, this.apiClient, lock); + new GlueLockAccessory(this, existingAccessory, this.apiClient as GlueApi, lock); this.api.updatePlatformAccessories([existingAccessory]); } else if (!lock) { @@ -58,7 +72,7 @@ export class GlueHomePlatformPlugin implements DynamicPlatformPlugin { const accessory = new this.api.platformAccessory(lock.description, lock.id); - new GlueLockAccessory(this, accessory, this.apiClient, lock); + new GlueLockAccessory(this, accessory, this.apiClient as GlueApi, lock); this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); } diff --git a/src/settings.ts b/src/settings.ts index 925c41b..c588a99 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,8 +1,15 @@ +import { platform, release } from 'os'; + /** * This is the name of the platform that users will use to register the plugin in the Homebridge config.json */ export const PLATFORM_NAME = 'GlueHomebridge'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +export const VERSION = require('../package.json').version; + +export const OS_VERSION = `${platform()} ${release()}`; + /** * This must match the name of your plugin as defined the package.json */ diff --git a/tsconfig.json b/tsconfig.json index bf07099..033eb73 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "rootDir": "./src", "strict": true, "esModuleInterop": true, - "noImplicitAny": false + "noImplicitAny": false, + "resolveJsonModule": true }, "include": [ "src/"