diff --git a/config.schema.json b/config.schema.json index 6ca4a64b..72f4a29a 100644 --- a/config.schema.json +++ b/config.schema.json @@ -96,6 +96,11 @@ "title": "Motion Sensor", "enum": ["Motion Sensor"] }, + + { + "title": "Hub 2", + "enum": ["Hub 2"] + }, { "title": "Contact Sensor", "enum": ["Contact Sensor"] @@ -184,7 +189,7 @@ ], "description": "Bluetooth (BLE) API is only available for the following Device Types: Humidifier, Meter, MeterPlus, Curtain, Bot, Motion Sensor, Contact Sensor, Plug Mini (US), Plug Mini (JP), & Color Bulb", "condition": { - "functionBody": "return (model.options && model.options.devices && model.options.devices[arrayIndices].deviceId && !model.options.devices[arrayIndices].hide_device);" + "functionBody": "return (model.options && model.options.devices && model.options.devices[arrayIndices].deviceId && !model.options.devices[arrayIndices].hide_device && model.options.devices[arrayIndices].configDeviceType !== 'Hub 2');" } }, "customBLEaddress": { @@ -203,6 +208,25 @@ "functionBody": "return (model.options && model.options.devices && model.options.devices[arrayIndices].deviceId && !model.options.devices[arrayIndices].hide_device && (model.options.devices[arrayIndices].connectionType === 'BLE' || model.options.devices[arrayIndices].connectionType === 'BLE/OpenAPI'));" } }, + "hidHub": { + "type": "object", + "properties": { + "hide_temperature": { + "title": "Hide Hub 2's Temperature Sensor", + "type": "boolean", + "condition": { + "functionBody": "return (model.options && model.options.devices && !model.options.devices[arrayIndices].hide_device && (model.options.devices[arrayIndices].configDeviceType === 'Hub 2') && model.options.devices[arrayIndices].deviceId);" + } + }, + "hide_humidity": { + "title": "Hide Hub 2's Humidity Sensor", + "type": "boolean", + "condition": { + "functionBody": "return (model.options && model.options.devices && !model.options.devices[arrayIndices].hide_device && (model.options.devices[arrayIndices].configDeviceType === 'Hub 2') && model.options.devices[arrayIndices].deviceId);" + } + } + } + }, "bot": { "type": "object", "properties": { @@ -1134,6 +1158,8 @@ "options.devices[].hide_device", "options.devices[].configDeviceType", "options.devices[].connectionType", + "options.devices[].hidHub.hide_temperature", + "options.devices[].hidHub.hide_humidity", "options.devices[].scanDuration", "options.devices[].disableCaching", "options.devices[].maxRetry", diff --git a/src/device/hub.ts b/src/device/hub.ts new file mode 100644 index 00000000..45897b3c --- /dev/null +++ b/src/device/hub.ts @@ -0,0 +1,395 @@ +import https from "https"; +import crypto from "crypto"; +import { Context } from "vm"; +import { IncomingMessage } from "http"; +import { interval } from "rxjs"; +import superStringify from "super-stringify"; +import { SwitchBotPlatform } from "../platform"; +import { Service, PlatformAccessory, CharacteristicValue } from "homebridge"; +import { device, devicesConfig, HostDomain, DevicePath } from "../settings"; +import { sleep } from "../utils"; + +export class Hub { + // Services + hubTemperatureSensor: Service; + hubHumiditySensor: Service; + + // Characteristic Values + CurrentRelativeHumidity!: number; + CurrentTemperature!: number; + + // OpenAPI Others + deviceStatus!: any; //deviceStatusResponse; + + // Config + set_minStep!: number; + updateRate!: number; + set_minLux!: number; + set_maxLux!: number; + scanDuration!: number; + deviceLogging!: string; + deviceRefreshRate!: number; + + // Connection + private readonly BLE = this.device.connectionType === "BLE" || this.device.connectionType === "BLE/OpenAPI"; + private readonly OpenAPI = this.device.connectionType === "OpenAPI" || this.device.connectionType === "BLE/OpenAPI"; + + constructor(private readonly platform: SwitchBotPlatform, private accessory: PlatformAccessory, public device: device & devicesConfig) { + // default placeholders + this.logs(device); + this.refreshRate(device); + + this.CurrentRelativeHumidity = accessory.context.CurrentRelativeHumidity; + this.CurrentTemperature = accessory.context.CurrentTemperature; + + // Retrieve initial values and updateHomekit + this.refreshStatus(); + + // set accessory information + accessory + .getService(this.platform.Service.AccessoryInformation)! + .setCharacteristic(this.platform.Characteristic.Manufacturer, "SwitchBot") + .setCharacteristic(this.platform.Characteristic.Model, accessory.context.model) + .setCharacteristic(this.platform.Characteristic.SerialNumber, device.deviceId) + .setCharacteristic(this.platform.Characteristic.FirmwareRevision, this.FirmwareRevision(accessory, device)) + .getCharacteristic(this.platform.Characteristic.FirmwareRevision) + .updateValue(this.FirmwareRevision(accessory, device)); + + // get the WindowCovering service if it exists, otherwise create a new WindowCovering service + // you can create multiple services for each accessory + (this.hubTemperatureSensor = + accessory.getService(this.platform.Service.TemperatureSensor) || accessory.addService(this.platform.Service.TemperatureSensor)), + `${device.deviceName} ${device.deviceType}`; + + (this.hubHumiditySensor = + accessory.getService(this.platform.Service.HumiditySensor) || accessory.addService(this.platform.Service.HumiditySensor)), + `${device.deviceName} ${device.deviceType}`; + + // 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: + // accessory.getService('NAME') ?? accessory.addService(this.platform.Service.WindowCovering, 'NAME', 'USER_DEFINED_SUBTYPE'); + + // set the service name, this is what is displayed as the default name on the Home app + // in this example we are using the name we stored in the `accessory.context` in the `discoverDevices` method. + this.hubTemperatureSensor.setCharacteristic(this.platform.Characteristic.Name, accessory.displayName); + if (!this.hubTemperatureSensor.testCharacteristic(this.platform.Characteristic.ConfiguredName)) { + this.hubTemperatureSensor.addCharacteristic(this.platform.Characteristic.ConfiguredName, accessory.displayName); + } + + this.hubHumiditySensor.setCharacteristic(this.platform.Characteristic.Name, accessory.displayName); + if (!this.hubHumiditySensor.testCharacteristic(this.platform.Characteristic.ConfiguredName)) { + this.hubHumiditySensor.addCharacteristic(this.platform.Characteristic.ConfiguredName, accessory.displayName); + } + + // each service must implement at-minimum the "required characteristics" for the given service type + // see https://developers.homebridge.io/#/service/WindowCovering + + // console.log(this.hubHumiditySensor); + + // Humidity Sensor Service + if (device.hub?.hide_humidity) { + this.debugLog(`${this.device.deviceType}: ${accessory.displayName} Removing Humidity Sensor Service`); + this.hubHumiditySensor = this.hubHumiditySensor.setCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, false); + accessory.removeService(this.hubHumiditySensor!); + } else if (!this.hubHumiditySensor) { + this.debugLog(`${this.device.deviceType}: ${accessory.displayName} Add Humidity Sensor Service`); + (this.hubHumiditySensor = + accessory.getService(this.platform.Service.HumiditySensor) || accessory.addService(this.platform.Service.HumiditySensor)), + `${device.deviceName} ${device.deviceType}`; + + this.hubHumiditySensor.setCharacteristic(this.platform.Characteristic.Name, `${accessory.displayName} Humidity Sensor`); + this.hubHumiditySensor.setCharacteristic(this.platform.Characteristic.ConfiguredName, `${accessory.displayName} Humidity Sensor`); + } else { + this.debugLog(`${this.device.deviceType}: ${accessory.displayName} Humidity Sensor Service Not Added`); + } + + // Temperature Sensor Service + if (device.hub?.hide_temperature) { + this.debugLog(`${this.device.deviceType}: ${accessory.displayName} Removing Temperature Sensor Service`); + this.hubTemperatureSensor = this.hubTemperatureSensor.setCharacteristic(this.platform.Characteristic.CurrentTemperature, false); + accessory.removeService(this.hubTemperatureSensor!); + } else if (!this.hubTemperatureSensor) { + this.debugLog(`${this.device.deviceType}: ${accessory.displayName} Add Temperature Sensor Service`); + (this.hubTemperatureSensor = + accessory.getService(this.platform.Service.TemperatureSensor) || accessory.addService(this.platform.Service.TemperatureSensor)), + `${device.deviceName} ${device.deviceType}`; + + this.hubTemperatureSensor.setCharacteristic(this.platform.Characteristic.Name, `${accessory.displayName} Temperature Sensor`); + this.hubTemperatureSensor.setCharacteristic(this.platform.Characteristic.ConfiguredName, `${accessory.displayName} Temperature Sensor`); + } else { + this.debugLog(`${this.device.deviceType}: ${accessory.displayName} Temperature Sensor Service Not Added`); + } + + // Update Homekit + this.updateHomeKitCharacteristics(); + + // Start an update interval + interval(this.deviceRefreshRate * 1000).subscribe(async () => { + await this.refreshStatus(); + }); + } + + /** + * Parse the device status from the SwitchBot api + */ + async parseStatus(): Promise { + if (this.OpenAPI && this.platform.config.credentials?.token) { + await this.openAPIparseStatus(); + } else { + await this.offlineOff(); + this.debugWarnLog( + `${this.device.deviceType}: ${this.accessory.displayName} Connection Type:` + ` ${this.device.connectionType}, parseStatus will not happen.`, + ); + } + } + + async openAPIparseStatus(): Promise { + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} openAPIparseStatus`); + this.infoLog(`update temperature and humidity for ${this.accessory.displayName}`); + // this.infoLog(`temp: ${this.CurrentTemperature}, humidity: ${this.CurrentRelativeHumidity}`); + this.hubTemperatureSensor.setCharacteristic(this.platform.Characteristic.CurrentTemperature, this.CurrentTemperature); + this.hubHumiditySensor.setCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, this.CurrentRelativeHumidity); + } + + async refreshStatus(): Promise { + if (this.OpenAPI && this.platform.config.credentials?.token) { + await this.openAPIRefreshStatus(); + } else { + await this.offlineOff(); + this.debugWarnLog(`${this.device.deviceType}: ${this.accessory.displayName} Connection Type: OpenAPI, refreshStatus will not happen.`); + } + } + + async openAPIRefreshStatus(): Promise { + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} openAPIRefreshStatus`); + try { + const t = Date.now(); + const nonce = "requestID"; + const data = this.platform.config.credentials?.token + t + nonce; + const signTerm = crypto.createHmac("sha256", this.platform.config.credentials?.secret).update(Buffer.from(data, "utf-8")).digest(); + const sign = signTerm.toString("base64"); + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} sign: ${sign}`); + const options = { + hostname: HostDomain, + port: 443, + path: `${DevicePath}/${this.device.deviceId}/status`, + method: "GET", + headers: { + Authorization: this.platform.config.credentials?.token, + sign: sign, + nonce: nonce, + t: t, + "Content-Type": "application/json", + }, + }; + const req = https.request(options, (res) => { + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} statusCode: ${res.statusCode}`); + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName}: ${res}`); + let rawData = ""; + res.on("data", (d) => { + rawData += d; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} d: ${d}`); + }); + res.on("end", () => { + try { + this.deviceStatus = JSON.parse(rawData); + this.CurrentTemperature = this.deviceStatus.body.temperature; + this.CurrentRelativeHumidity = this.deviceStatus.body.humidity; + this.infoLog(`${this.device.deviceType}: ${this.accessory.displayName} refreshStatus: ${superStringify(this.deviceStatus)}`); + this.openAPIparseStatus(); + this.updateHomeKitCharacteristics(); + } catch (e: any) { + this.errorLog(`${this.device.deviceType}: ${this.accessory.displayName} error message: ${e.message}`); + } + }); + }); + req.on("error", (e: any) => { + this.errorLog(`${this.device.deviceType}: ${this.accessory.displayName} error message: ${e.message}`); + }); + req.end(); + } catch (e: any) { + this.apiError(e); + this.errorLog( + `${this.device.deviceType}: ${this.accessory.displayName} failed openAPIRefreshStatus with ${this.device.connectionType}` + + ` Connection, Error Message: ${superStringify(e.message)}`, + ); + } + } + + async retry({ max, fn }: { max: number; fn: { (): any; (): Promise } }): Promise { + return fn().catch(async (e: any) => { + if (max === 0) { + throw e; + } + this.infoLog(e); + this.infoLog(`${this.device.deviceType}: ${this.accessory.displayName} Retrying`); + await sleep(1000); + return this.retry({ max: max - 1, fn }); + }); + } + + maxRetry(): number { + if (this.device.maxRetry) { + return this.device.maxRetry; + } else { + return 5; + } + } + + /** + * Handle requests to set the value of the "Target Position" characteristic + */ + + async updateHomeKitCharacteristics(): Promise { + this.hubHumiditySensor?.updateCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, this.CurrentRelativeHumidity); + this.infoLog(this.CurrentRelativeHumidity); + this.hubTemperatureSensor?.updateCharacteristic(this.platform.Characteristic.CurrentTemperature, this.CurrentTemperature); + } + + async statusCode({ res }: { res: IncomingMessage }): Promise { + switch (res.statusCode) { + case 151: + this.errorLog(`${this.device.deviceType}: ${this.accessory.displayName} Command not supported by this device type.`); + break; + case 152: + this.errorLog(`${this.device.deviceType}: ${this.accessory.displayName} Device not found.`); + break; + case 160: + this.errorLog(`${this.device.deviceType}: ${this.accessory.displayName} Command is not supported.`); + break; + case 161: + this.errorLog(`${this.device.deviceType}: ${this.accessory.displayName} Device is offline.`); + this.offlineOff(); + break; + case 171: + this.errorLog(`${this.device.deviceType}: ${this.accessory.displayName} Hub Device is offline. Hub: ${this.device.hubDeviceId}`); + this.offlineOff(); + break; + case 190: + this.errorLog( + `${this.device.deviceType}: ${this.accessory.displayName} Device internal error due to device states not synchronized with server,` + + ` Or command: ${superStringify(res)} format is invalid`, + ); + break; + case 100: + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Command successfully sent.`); + break; + default: + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Unknown statusCode.`); + } + } + + async offlineOff(): Promise { + if (this.device.offline) { + await this.updateHomeKitCharacteristics(); + } + } + + async apiError(e: any): Promise { + this.hubTemperatureSensor?.updateCharacteristic(this.platform.Characteristic.CurrentTemperature, e); + this.hubHumiditySensor?.updateCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, e); + } + + FirmwareRevision(accessory: PlatformAccessory, device: device & devicesConfig): CharacteristicValue { + let FirmwareRevision: string; + this.debugLog( + `${this.device.deviceType}: ${this.accessory.displayName}` + ` accessory.context.FirmwareRevision: ${accessory.context.FirmwareRevision}`, + ); + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} device.firmware: ${device.firmware}`); + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} this.platform.version: ${this.platform.version}`); + if (accessory.context.FirmwareRevision) { + FirmwareRevision = accessory.context.FirmwareRevision; + } else if (device.firmware) { + FirmwareRevision = device.firmware; + } else { + FirmwareRevision = this.platform.version; + } + return FirmwareRevision; + } + + async refreshRate(device: device & devicesConfig): Promise { + // refreshRate + if (device.refreshRate) { + this.deviceRefreshRate = this.accessory.context.refreshRate = device.refreshRate; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Using Device Config refreshRate: ${this.deviceRefreshRate}`); + } else if (this.platform.config.options!.refreshRate) { + this.deviceRefreshRate = this.accessory.context.refreshRate = this.platform.config.options!.refreshRate; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Using Platform Config refreshRate: ${this.deviceRefreshRate}`); + } + // updateRate + if (device?.curtain?.updateRate) { + this.updateRate = device?.curtain?.updateRate; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Using Device Config Curtain updateRate: ${this.updateRate}`); + } else { + this.updateRate = 7; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Using Default Curtain updateRate: ${this.updateRate}`); + } + } + + async logs(device: device & devicesConfig): Promise { + if (this.platform.debugMode) { + this.deviceLogging = this.accessory.context.logging = "debugMode"; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Using Debug Mode Logging: ${this.deviceLogging}`); + } else if (device.logging) { + this.deviceLogging = this.accessory.context.logging = device.logging; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Using Device Config Logging: ${this.deviceLogging}`); + } else if (this.platform.config.options?.logging) { + this.deviceLogging = this.accessory.context.logging = this.platform.config.options?.logging; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Using Platform Config Logging: ${this.deviceLogging}`); + } else { + this.deviceLogging = this.accessory.context.logging = "standard"; + this.debugLog(`${this.device.deviceType}: ${this.accessory.displayName} Logging Not Set, Using: ${this.deviceLogging}`); + } + } + + /** + * Logging for Device + */ + infoLog(...log: any[]): void { + if (this.enablingDeviceLogging()) { + this.platform.log.info(String(...log)); + } + } + + warnLog(...log: any[]): void { + if (this.enablingDeviceLogging()) { + this.platform.log.warn(String(...log)); + } + } + + debugWarnLog(...log: any[]): void { + if (this.enablingDeviceLogging()) { + if (this.deviceLogging?.includes("debug")) { + this.platform.log.warn("[DEBUG]", String(...log)); + } + } + } + + errorLog(...log: any[]): void { + if (this.enablingDeviceLogging()) { + this.platform.log.error(String(...log)); + } + } + + debugErrorLog(...log: any[]): void { + if (this.enablingDeviceLogging()) { + if (this.deviceLogging?.includes("debug")) { + this.platform.log.error("[DEBUG]", String(...log)); + } + } + } + + debugLog(...log: any[]): void { + if (this.enablingDeviceLogging()) { + if (this.deviceLogging === "debug") { + this.platform.log.info("[DEBUG]", String(...log)); + } else { + this.platform.log.debug(String(...log)); + } + } + } + + enablingDeviceLogging(): boolean { + return this.deviceLogging.includes("debug") || this.deviceLogging === "standard"; + } +} diff --git a/src/platform.ts b/src/platform.ts index 051fb8ed..563c1a8a 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -3,6 +3,7 @@ import { Plug } from './device/plug'; import { Lock } from './device/lock'; import { Meter } from './device/meter'; import { Motion } from './device/motion'; +import { Hub } from './device/hub'; import { Contact } from './device/contact'; import { Curtain } from './device/curtain'; import { IOSensor } from './device/iosensor'; @@ -414,6 +415,7 @@ export class SwitchBotPlatform implements DynamicPlatformPlugin { break; case 'Hub 2': this.debugLog(`Discovered ${device.deviceType}: ${device.deviceId}`); + this.createHub2(device); break; case 'Bot': this.debugLog(`Discovered ${device.deviceType}: ${device.deviceId}`); @@ -730,7 +732,7 @@ export class SwitchBotPlatform implements DynamicPlatformPlugin { } } - private async createMeterPlus(device: device & devicesConfig) { +private async createMeterPlus(device: device & devicesConfig) { const uuid = this.api.hap.uuid.generate(`${device.deviceId}-${device.deviceType}`); // see if an accessory with the same uuid has already been registered and restored from @@ -740,6 +742,7 @@ export class SwitchBotPlatform implements DynamicPlatformPlugin { if (existingAccessory) { // the accessory already exists if (await this.registerDevice(device)) { + // console.log("existingAccessory", existingAccessory); // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.: existingAccessory.context.model = device.deviceType; existingAccessory.context.deviceID = device.deviceId; @@ -786,6 +789,63 @@ export class SwitchBotPlatform implements DynamicPlatformPlugin { } } + private async createHub2(device: device & devicesConfig) { + const uuid = this.api.hap.uuid.generate(`${device.deviceId}-${device.deviceType}`); + + // 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 === uuid); + + if (existingAccessory) { + // the accessory already exists + if (await this.registerDevice(device)) { + // console.log("existingAccessory", existingAccessory); + // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.: + existingAccessory.context.model = device.deviceType; + existingAccessory.context.deviceID = device.deviceId; + existingAccessory.displayName = device.configDeviceName || device.deviceName; + existingAccessory.context.firmwareRevision = device.firmware; + existingAccessory.context.deviceType = `SwitchBot: ${device.deviceType}`; + this.infoLog(`Restoring existing accessory from cache: ${existingAccessory.displayName} DeviceID: ${device.deviceId}`); + existingAccessory.context.connectionType = await this.connectionType(device); + this.api.updatePlatformAccessories([existingAccessory]); + // create the accessory handler for the restored accessory + // this is imported from `platformAccessory.ts` + new Hub(this, existingAccessory, device); + this.debugLog(`${device.deviceType} uuid: ${device.deviceId}-${device.deviceType}, (${existingAccessory.UUID})`); + } else { + this.unregisterPlatformAccessories(existingAccessory); + } + } else if (await this.registerDevice(device)) { + // the accessory does not yet exist, so we need to create it + if (!device.external) { + this.infoLog(`Adding new accessory: ${device.deviceName} ${device.deviceType} DeviceID: ${device.deviceId}`); + } + + // create a new accessory + const accessory = new this.api.platformAccessory(device.deviceName, uuid); + + // store a copy of the device object in the `accessory.context` + // the `context` property can be used to store any data about the accessory you may need + accessory.context.device = device; + accessory.context.model = device.deviceType; + accessory.context.deviceID = device.deviceId; + accessory.context.firmwareRevision = device.firmware; + accessory.context.deviceType = `SwitchBot: ${device.deviceType}`; + accessory.context.connectionType = await this.connectionType(device); + // create the accessory handler for the newly create accessory + // this is imported from `platformAccessory.ts` + new Hub(this, accessory, device); + this.debugLog(`${device.deviceType} uuid: ${device.deviceId}-${device.deviceType}, (${accessory.UUID})`); + + // publish device externally or link the accessory to your platform + this.externalOrPlatform(device, accessory); + this.accessories.push(accessory); + } else { + this.debugLog(`Device not registered: ${device.deviceName} ${device.deviceType} DeviceID: ${device.deviceId}`); + } + } + private async createIOSensor(device: device & devicesConfig) { const uuid = this.api.hap.uuid.generate(`${device.deviceId}-${device.deviceType}`); diff --git a/src/settings.ts b/src/settings.ts index 90c69a02..50209167 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -73,6 +73,7 @@ export interface devicesConfig extends device { ceilinglight?: ceilinglight; plug?: Record; lock?: lock; + hub?: hub; } export type meter = { @@ -152,6 +153,11 @@ export type lock = { hide_contactsensor?: boolean; }; +export type hub = { + hide_temperature?: boolean; + hide_humidity?: boolean; +}; + export interface irDevicesConfig extends irdevice { configRemoteType?: string; connectionType?: string;