diff --git a/src/config.ts b/src/config.ts index f426f48..e6fcef0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,4 +10,9 @@ export interface MySmartRollerShadesConfig { export interface MySmartRollerShadesAuth { username: string; password: string; -} \ No newline at end of file +} + +export interface MySmartRollerShade { + id: string; + name: string; +} diff --git a/src/platform.ts b/src/platform.ts index d005fcf..d11cc24 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,3 +1,5 @@ +import rp from 'request-promise'; +import jwt from 'jsonwebtoken'; import { API, DynamicPlatformPlugin, @@ -9,12 +11,18 @@ import { } from 'homebridge'; import { PLATFORM_NAME, -// PLUGIN_NAME, + PLUGIN_NAME, + MYSMARTBLINDS_DOMAIN, + TILTSMARTHOME_OPTIONS, + TILTSMARTHOME_URL, + } from './settings'; import { MySmartRollerShadesConfig, MySmartRollerShadesAuth, + MySmartRollerShade, } from './config'; +import { MySmartRollerShadesAccessory } from './platformAccessory'; export class MySmartRollerShadesBridgePlatform implements DynamicPlatformPlugin { public readonly Service: typeof Service = this.api.hap.Service; @@ -23,6 +31,17 @@ export class MySmartRollerShadesBridgePlatform implements DynamicPlatformPlugin // this is used to track restored cached accessories public readonly accessories: PlatformAccessory[] = []; auth!: MySmartRollerShadesAuth; + authToken!: string; + authTokenInterval?: NodeJS.Timeout; + requestOptions!: { + method: string; + uri: string; + json: boolean; + headers: { + Authorization: string; + }; + }; + constructor( public readonly log: Logger, @@ -41,10 +60,10 @@ export class MySmartRollerShadesBridgePlatform implements DynamicPlatformPlugin try { if (!this.config.username) { - throw new Error('MySmartBlinds Bridge - You must provide a username'); + throw new Error('MySmartRollerShades Bridge - You must provide a username'); } if (!this.config.password) { - throw new Error('MySmartBlinds Bridge - You must provide a password'); + throw new Error('MySmartRollershades Bridge - You must provide a password'); } this.auth = { username: this.config.username, @@ -69,7 +88,72 @@ export class MySmartRollerShadesBridgePlatform implements DynamicPlatformPlugin this.accessories.push(accessory); } + refreshAuthToken() { + return rp({ + method: 'POST', + uri: `https://${MYSMARTBLINDS_DOMAIN}/oauth/token`, + json: true, + body: Object.assign( + {}, + TILTSMARTHOME_OPTIONS, + this.auth, + ), + }).then((response) => { + this.authToken = response.access_token; + this.requestOptions = { + method: 'GET', + uri: TILTSMARTHOME_URL, + json: true, + headers: { Authorization: `Bearer ${response.access_token}` }, + }; + + if (this.config.allowDebug) { + const authTokenExpireDate = new Date((jwt.decode(response.id_token || '{ exp: 0 }') as { exp: number }).exp * 1000).toISOString(); + this.log.info(`authToken refresh, now expires ${authTokenExpireDate}`); + } + }); + } + discoverDevices() { this.log.info('This plugin is still a work in progress'); + this.refreshAuthToken().then(() => { + this.authTokenInterval = setInterval(this.refreshAuthToken.bind(this), 1000 * 60 * 60 * 8); + rp(this.requestOptions).then((response) => { + response.rooms.forEach((room: { rollerShades: MySmartRollerShade[] }) => { + room.rollerShades.forEach((rollerShade) => { + const { + id: shadeID, + name: shadeName, + } = rollerShade; + const uuid = this.api.hap.uuid.generate(shadeID); + + const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid); + if (existingAccessory) { + this.log.debug(`Restore cached roller shade: ${shadeName}`); + + new MySmartRollerShadesAccessory(this, existingAccessory); + + this.api.updatePlatformAccessories([existingAccessory]); + } else { + this.log.info(`Adding new roller shade: ${shadeName}`); + + // create a new accessory + const accessory = new this.api.platformAccessory(shadeName, uuid); + accessory.context.shade = { + name: shadeName, + id: shadeID, + shadePosition: 100, // change to real value via get shade + batteryLevel: 100, // change to real value via get shade + }; + + new MySmartRollerShadesAccessory(this, accessory); + + this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); + } + }); + // need to figure out how to handle deleted roller shades + }); + }); + }); } } \ No newline at end of file diff --git a/src/platformAccessory.ts b/src/platformAccessory.ts new file mode 100644 index 0000000..7d56b8f --- /dev/null +++ b/src/platformAccessory.ts @@ -0,0 +1,122 @@ +import { + Service, + PlatformAccessory, + CharacteristicValue, + CharacteristicSetCallback, +} from 'homebridge'; +// import rp from 'request-promise'; +import { MySmartRollerShadesBridgePlatform } from './platform'; + +export class MySmartRollerShadesAccessory { + service!: Service; + batteryService: Service; + statusLog: boolean; + pollingInterval: number; + name: string; + id: string; + platform: MySmartRollerShadesBridgePlatform; + accessory: PlatformAccessory; + allowDebug: boolean; + + constructor( + platform: MySmartRollerShadesBridgePlatform, + accessory: PlatformAccessory, + ) { + this.platform = platform; + this.name = accessory.context.shade.name; + this.id = accessory.context.shade.id; + this.statusLog = platform.config.statusLog || false; + this.pollingInterval = platform.config.pollingInterval || 0; + this.allowDebug = platform.config.allowDebug || false; + + accessory.getService(this.platform.Service.AccessoryInformation)! + .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Tilt Smart Home') + .setCharacteristic(this.platform.Characteristic.Model, 'MySmartRollerShades') + .setCharacteristic(this.platform.Characteristic.SerialNumber, this.id); + + this.service = accessory.getService(this.platform.Service.WindowCovering) + || accessory.addService(this.platform.Service.WindowCovering); + + this.service.setCharacteristic(this.platform.Characteristic.Name, this.name); + + this.service.getCharacteristic(this.platform.Characteristic.TargetPosition) + .on('set', this.setTargetPosition.bind(this)); + this.updatePosition(accessory.context.shade.shadePosition); + + this.batteryService = accessory.getService(this.platform.Service.BatteryService) + || accessory.addService(this.platform.Service.BatteryService, `${this.name} Battery`, `${this.id} Battery`); + this.updateBattery(accessory.context.shade.batteryLevel); + + this.accessory = accessory; + + if (this.pollingInterval > 0) { + if (this.allowDebug) { + this.platform.log.info(`Begin polling for ${this.name}`); + } + setTimeout(() => this.refreshRollerShade(), this.pollingInterval * 1000 * 60); + } + } + + updatePosition(currentPosition: number) { + let reportCurrentPosition = currentPosition; + + if (reportCurrentPosition === 99) { + reportCurrentPosition = 100; + } + if (reportCurrentPosition === 1) { + reportCurrentPosition = 0; + } + if (this.statusLog) { + this.platform.log.info(`STATUS: ${this.name} updateCurrentPosition : ${reportCurrentPosition} (Actual ${currentPosition})`); + } + + this.service.updateCharacteristic(this.platform.Characteristic.TargetPosition, reportCurrentPosition); + this.service.updateCharacteristic(this.platform.Characteristic.CurrentPosition, reportCurrentPosition); + this.service.updateCharacteristic(this.platform.Characteristic.PositionState, this.platform.Characteristic.PositionState.STOPPED); + } + + updateBattery(batteryLevel: number) { + const { + StatusLowBattery, + } = this.platform.Characteristic; + // value of -1 means data was not sent correctly, so ignore it for now + + this.batteryService.updateCharacteristic(this.platform.Characteristic.BatteryLevel, batteryLevel); + this.batteryService + .updateCharacteristic( + StatusLowBattery, + (batteryLevel < 20 && batteryLevel !== -1) ? StatusLowBattery.BATTERY_LEVEL_LOW : StatusLowBattery.BATTERY_LEVEL_NORMAL, + ); + } + + setTargetPosition(value: CharacteristicValue, callback: CharacteristicSetCallback) { + const targetPosition = value as number; + this.service.updateCharacteristic(this.platform.Characteristic.TargetPosition, targetPosition); + + this.platform.log.info(`${this.name} setTargetPosition to ${value}`); + + /* need to add update logic */ + // update current position + this.updatePosition(targetPosition); + + this.platform.log.info(`${this.name} currentPosition is now ${targetPosition}`); + callback(null); + } + + refreshRollerShade() { + if (this.allowDebug) { + this.platform.log.info(`Refresh roller shade ${this.name}`); + } + /* work in progress + const shadeState = response.shadeState; + this.updatePosition(shadeState.position); + this.updateBattery(shadeState.batteryLevel as number); + let refreshShadeimeOut = this.pollingInterval * 1000 * 60; // convert minutes to milliseconds + if (response.headers['x-ratelimit-reset']) { + refreshShadeTimeOut = new Date(parseInt(response.headers['x-ratelimit-reset']) * 1000).getTime() - new Date().getTime(); + this.platform.log.warn(`Rate Limit reached, refresh for ${this.name} delay to ${new Date(response.headers['x-ratelimit-reset'])}`); + } + setTimeout(() => this.refreshRollerShade(), refreshShadeTimeOut); + */ + } +}