UNPKG

homebridge-am43-blinds-bluez

Version:

A homebridge plugin to control AM43 based shade motors in HomeKit. These include the A OK and Zemismart bluetooth based motors. This plugin requires a Homebridge host that supports Bluetooth 4.0

567 lines (518 loc) 20.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AM43Device = void 0; const events_1 = require("events"); const commands_1 = require("./utils/commands"); const values_1 = require("./utils/values"); const debug_1 = require("debug"); const ble_1 = require("./utils/ble"); const ACK_TIMEOUT = 1000; const debug = (0, debug_1.default)("AM43"); class AM43Device extends events_1.EventEmitter { constructor({ uuid, config, interactionTimeout, log, }) { super(); this.config = config; this.uuid = this.id = uuid; this.name = config.name ?? `AM43 Blind: ${uuid}`; this.hasRunFirstConnect = false; this.batteryPercentage = 50; this.position = 50; this.targetPosition = null; this.primaryServiceUUID = ""; this.controlCharacteristicUUID = ""; this.log = log; this.tiltMotor = null; this.tilt = null; this.targetTilt = null; } async firstConnect() { const { device } = await ble_1.ble.connectToDevice(this.uuid); const name = await device.getName(); this.name = name; this.log.info(`Connected - ${this.description}`); await this.setupLongUUIDs(); await this.subscribeToNotifications(); if (this.config.pass_code) { try { await this.authWithPassCode(this.config.pass_code); } catch (err) { this.log.error(`Passcode auth failed for ${this.description}`); } } this.hasRunFirstConnect = true; return true; } addTiltMotor(subDevice) { this.tiltMotor = subDevice; this.tiltMotor.on("ble:position", (position) => { if (this.config.has_tilt) { const tilt = Math.round((position / 100) * 180 - 90); this.tilt = tilt; this.emit("tilt", tilt); } }); } get description() { return `${this.name} : ${this.uuid}`; } async setTilt(tilt) { this.targetTilt = tilt; const position = ((tilt + 90) / 180) * 100; if (!this.tiltMotor) return false; return await this.tiltMotor.setPosition(position); } async subscribeToNotifications() { this.notificationCallback = (data) => { const eventName = values_1.NOTIFICATION_TO_EVENT[data.toString("hex")]; if (eventName) this.emit(`ble:${eventName}`); switch (data[1]) { case values_1.DeviceValues.AM43_COMMAND_PASSCODE: this.emit("ble:auth", data[3] === values_1.DeviceValues.AM43_RESPONSE_ACK); break; case values_1.DeviceValues.AM43_COMMAND_ID_GET_POSITION: this.position = data[5]; this.log.debug(`${this.name} - GET_POSITION ${this.position}`); this.emit("ble:position", this.position); break; case values_1.DeviceValues.AM43_COMMAND_ID_GET_BATTERYSTATUS: const batteryPercentage = data[7]; this.batteryPercentage = batteryPercentage; this.emit("ble:battery", this.batteryPercentage); break; case values_1.DeviceValues.AM43_NOTIFY_POSITION: const position = data[4]; this.log.debug(`${this.name} - NOTIFY_POSITION ${position}`); this.position = position; this.emit("ble:position", this.position); break; case values_1.DeviceValues.AM43_COMMAND_ID_SET_MOVE: this.log.debug(`${this.name} - Set move notify received`); if (data[3] == values_1.DeviceValues.AM43_RESPONSE_ACK) { this.log.debug(`${this.name} - ACK`); } else if (data[3] == values_1.DeviceValues.AM43_RESPONSE_NACK) { this.log.debug(`${this.name} - NACK`); } break; case values_1.DeviceValues.AM43_COMMAND_ID_SET_POSITION: this.log.debug(`${this.name} - Set position notify received ${data[3] == values_1.DeviceValues.AM43_RESPONSE_ACK}`); this.emit("ble:position_set", data[3] === values_1.DeviceValues.AM43_RESPONSE_ACK); break; default: break; } }; await ble_1.ble.subscribe(this.uuid, this.primaryServiceUUID, this.controlCharacteristicUUID, this.notificationCallback); } async reconnect() { ble_1.ble.connectToDevice(this.uuid).then(async ({ device }) => { this.log.info(`Connected - ${this.description}`); await this.subscribeToNotifications(); if (this.config.pass_code) { try { await this.authWithPassCode(this.config.pass_code); } catch (err) { this.log.error(`Passcode auth failed for ${this.description}`); } } }); } async disconnect() { await this.unsubscribeToNotifications(); await ble_1.ble.disconnectDevice(this.uuid); } async unsubscribeToNotifications() { if (this.notificationCallback) await ble_1.ble.unsubscribe(this.notificationCallback); } async setupLongUUIDs() { const uuids = await ble_1.ble.getServiceAndCharacteristicUUIDs(this.uuid); this.primaryServiceUUID = uuids.serviceUUID; this.controlCharacteristicUUID = uuids.characteristicUUID; } async authWithPassCode(passcode) { // await new Promise((resolve) => setTimeout(resolve, 250)) const data16Bit = new Uint16Array([parseInt(passcode)]); const data = new Uint8Array(data16Bit.buffer).reverse(); this.log.info("Authing..."); return new Promise((resolve, reject) => { ble_1.ble.writeValue(this.uuid, this.primaryServiceUUID, this.controlCharacteristicUUID, (0, commands_1.buildCommandBuffer)(values_1.DeviceValues.AM43_COMMAND_PASSCODE, data)); this.callbackOrTimeout("ble:auth", (success) => { if (success) { resolve(true); } else { resolve(false); } }, () => { this.log.error("Auth Timeout..."); reject(); }); }); } callbackOrTimeout(once, successCallback, timeoutCallback) { const callback = (...args) => { clearTimeout(timeout); successCallback(...args); }; this.once(once, callback); const timeout = setTimeout(() => { this.removeListener(once, callback); timeoutCallback(); }, ACK_TIMEOUT); } async updatePosition() { if (!this.hasRunFirstConnect) return 0; return new Promise((resolve, reject) => { ble_1.ble.writeValue(this.uuid, this.primaryServiceUUID, this.controlCharacteristicUUID, (0, commands_1.buildCommandBuffer)(values_1.DeviceValues.AM43_COMMAND_ID_GET_POSITION, [0x1])); this.callbackOrTimeout("ble:position", (position) => resolve(position), () => resolve(false)); }); } async updateTilt() { if (!this.hasRunFirstConnect) return 0; const tiltPosition = await this.tiltMotor?.updatePosition(); if (tiltPosition === false || tiltPosition === undefined) return false; const tilt = Math.round((tiltPosition / 100) * 180 - 90); this.tilt = tilt; return tilt; } async updateBatteryStatus() { if (!this.hasRunFirstConnect) return 0; return new Promise((resolve, reject) => { ble_1.ble.writeValue(this.uuid, this.primaryServiceUUID, this.controlCharacteristicUUID, (0, commands_1.buildCommandBuffer)(values_1.DeviceValues.AM43_COMMAND_ID_GET_BATTERYSTATUS, [ 0x1, ])); this.callbackOrTimeout("ble:battery", (percentage) => resolve(percentage), () => resolve(false)); }); } getDirection() { const NUMBER_FUDGE = 4; const closeEnough = this.targetPosition && this.position > this.targetPosition - NUMBER_FUDGE / 2 && this.position < this.targetPosition + NUMBER_FUDGE / 2; this.log.debug(`getDirection ${closeEnough} ${this.targetPosition} ${this.position}`); if (!this.targetPosition || closeEnough) return values_1.DIRECTION.STOP; if (this.targetPosition < this.position) return values_1.DIRECTION.OPEN; if (this.targetPosition > this.position) return values_1.DIRECTION.CLOSE; } async setPosition(target) { return new Promise((resolve, reject) => { this.targetPosition = target; ble_1.ble.writeValue(this.uuid, this.primaryServiceUUID, this.controlCharacteristicUUID, (0, commands_1.buildCommandBuffer)(values_1.DeviceValues.AM43_COMMAND_ID_SET_POSITION, [target])); this.callbackOrTimeout("ble:position_set", (success) => resolve(success), () => resolve(false)); }); } } exports.AM43Device = AM43Device; /* class AM43Device extends EventEmitter { // private peripheral: noble.Peripheral private connectingPromise: null | Promise<void> // private discoveringPromise: null | Promise<noble.ServicesAndCharacteristics> // private blindsControlCharacteristic: null | noble.Characteristic private positionHistory: number[] private interactionTimeout: number private disconnectTimeout: NodeJS.Timeout | null // name: string id: string address: string description: string isConnected: boolean position: number targetPosition: null | number direction: 0 | 1 | 2 batteryPercentage: number config: PerDeviceConfig constructor( // peripheral: noble.Peripheral, config: PerDeviceConfig, interactionTimeout: number ) { super() // this.peripheral = peripheral this.config = config // this.name = // peripheral.advertisement?.localName || // peripheral.address || // `AM43 Blind ${peripheral.id}` this.id = peripheral.id || peripheral.uuid || peripheral.address || this.name this.address = peripheral.address let addressDesc = this.peripheral.address != null ? this.peripheral.address : this.peripheral.id this.description = `${this.name} (${addressDesc})` this.interactionTimeout = interactionTimeout this.disconnectTimeout = null this.isConnected = false this.peripheral.on("connect", () => { this.debugLog(`Device connected: ${this.id}`) this.isConnected = true }) this.peripheral.on("disconnect", () => { this.debugLog(`Device disconnected: ${this.id}`) this.connectingPromise = null this.discoveringPromise = null this.blindsControlCharacteristic = null this.isConnected = false }) this.blindsControlCharacteristic = null this.position = 0 this.targetPosition = null this.direction = 2 // 0: Down/Decreating, 1: Up/Increasing, 2: Stopped this.batteryPercentage = 50 this.positionHistory = [] this.connectingPromise = null this.discoveringPromise = null this.startDisconnectTimeout() } debugLog(info: string) { debug(`${this.description}: ${info}`) } setBlindsControlCharacteristic(characteristic: noble.Characteristic) { this.blindsControlCharacteristic = characteristic this.blindsControlCharacteristic.on("data", (data: number[]) => { this.debugLog("--------Notification--------") const dataArray = new Uint8Array(data) this.debugLog(`Data received:` + dataArray) let percentage: number | null = null switch (data[1]) { case DeviceValues.AM43_COMMAND_ID_GET_POSITION: this.debugLog("Position update received") percentage = dataArray[5] this.debugLog(`Closed Percentage ${percentage}`) this.position = percentage this.positionHistory.unshift(percentage) this.positionHistory.length = Math.min( DeviceValues.POSITION_HISTORY_LENGTH, this.positionHistory.length ) this.emit("position", this.position) break case DeviceValues.AM43_COMMAND_ID_GET_LIGHTSENSOR: this.debugLog("light sensor update received") percentage = dataArray[4] this.debugLog(`Light level ${percentage}`) this.emit("lightLevel", percentage) break case DeviceValues.AM43_COMMAND_ID_GET_BATTERYSTATUS: this.debugLog("Battery Status update received") percentage = dataArray[7] this.debugLog(`Battery Percentage ${percentage}`) this.batteryPercentage = percentage this.emit("batteryPercentage", this.batteryPercentage) break case DeviceValues.AM43_NOTIFY_POSITION: this.debugLog("Position notify received") percentage = dataArray[4] this.debugLog(`Closed Percentage ${percentage}`) this.position = percentage this.positionHistory.unshift(percentage) this.positionHistory.length = Math.min( DeviceValues.POSITION_HISTORY_LENGTH, this.positionHistory.length ) this.emit("position", this.position) break case DeviceValues.AM43_COMMAND_ID_SET_MOVE: this.debugLog("Set move notify received") if (dataArray[3] == DeviceValues.AM43_RESPONSE_ACK) { this.debugLog("Set move acknowledged") } else if (dataArray[3] == DeviceValues.AM43_RESPONSE_NACK) { this.debugLog("Set move denied") } break case DeviceValues.AM43_COMMAND_ID_SET_POSITION: this.debugLog("Set position notify received") if (dataArray[3] == DeviceValues.AM43_RESPONSE_ACK) { this.debugLog("Set position acknowledged") } else if (dataArray[3] == DeviceValues.AM43_RESPONSE_NACK) { this.debugLog("Set position denied") } break default: break } if (this.targetPosition != null && this.position != null) { let direction: 0 | 1 | 2 = this.targetPosition < this.position ? DIRECTION.OPEN : DIRECTION.CLOSE let targetPosition: null | number = this.targetPosition if (this.position == this.targetPosition || this.checkIfStopped()) { this.debugLog( `Target position ${this.targetPosition} reached @ ${this.position}` ) targetPosition = null } if (targetPosition == null) direction = DIRECTION.STOP if (direction != this.direction) { this.direction = direction this.emit("direction", this.direction) } if (targetPosition != this.targetPosition) { this.targetPosition = targetPosition this.emit("targetPosition", this.targetPosition) } } }) this.blindsControlCharacteristic.subscribe((error) => { if (error) { this.debugLog("Failed to subsribe to notifications") } else { this.debugLog("Subscribed to notifications") } }) this.connectingPromise = null this.discoveringPromise = null } async prepareAsync() { if (!this.isConnected) await this.connectAsync() await this.updatePositionAsync() } async connectAsync() { if (!this.isConnected) { if (this.connectingPromise == null) this.connectingPromise = this.peripheral.connectAsync() await this.connectingPromise this.connectingPromise = null } if (this.discoveringPromise == null) this.discoveringPromise = this.peripheral.discoverSomeServicesAndCharacteristicsAsync( [DeviceValues.AM43_SERVICE_ID], [DeviceValues.AM43_CHARACTERISTIC_ID] ) const { characteristics } = await this.discoveringPromise this.discoveringPromise = null this.setBlindsControlCharacteristic(characteristics[0]) if (this.config.pass_code) await this.authWithPassCode(this.config.pass_code) } async authWithPassCode(passcode: string) { await new Promise((resolve) => setTimeout(resolve, 250)) const data16Bit = new Uint16Array([parseInt(passcode)]) const data = new Uint8Array(data16Bit.buffer).reverse() await this.sendCommandAsync(DeviceValues.AM43_COMMAND_PASSCODE, data) } async disconnectAsync() { this.isConnected = false this.connectingPromise = null this.discoveringPromise = null this.blindsControlCharacteristic = null await this.peripheral.disconnectAsync() } async enableNotificationsAsync() { try { await this.blindsControlCharacteristic?.subscribeAsync() this.debugLog("Subscribed to notifications") } catch (e) { this.debugLog("Failed to subsribe to notifications") } } private startDisconnectTimeout(): void { if (this.interactionTimeout > 0) { if (this.disconnectTimeout) clearTimeout(this.disconnectTimeout) this.disconnectTimeout = setTimeout( () => this.disconnectAsync(), this.interactionTimeout * 1000 ) } } async setPositionAsync(position: number, trackPosition: boolean) { this.startDisconnectTimeout() this.targetPosition = position await this.sendCommandAsync(DeviceValues.AM43_COMMAND_ID_SET_POSITION, [ position, ]) if (trackPosition == true) this.trackCurrentPosition() } trackCurrentPosition() { setTimeout(async () => { await this.updatePositionAsync() if (this.targetPosition != null) this.trackCurrentPosition() }, 1000) } checkIfStopped() { if (this.positionHistory.length < DeviceValues.POSITION_HISTORY_LENGTH) return false return this.positionHistory.every((v) => v === this.positionHistory[0]) } async openAsync() { this.startDisconnectTimeout() this.targetPosition = 0 this.direction = DIRECTION.OPEN await this.sendCommandAsync(DeviceValues.AM43_COMMAND_ID_SET_MOVE, [ DeviceValues.AM43_MOVE_OPEN, ]) this.emit("direction", this.direction) this.emit("targetPosition", this.targetPosition) } async closeAsync() { this.startDisconnectTimeout() this.targetPosition = 100 this.direction = DIRECTION.CLOSE await this.sendCommandAsync(DeviceValues.AM43_COMMAND_ID_SET_MOVE, [ DeviceValues.AM43_MOVE_CLOSE, ]) this.emit("direction", this.direction) this.emit("targetPosition", this.targetPosition) } async stopAsync() { this.startDisconnectTimeout() this.targetPosition = null this.direction = DIRECTION.STOP await this.sendCommandAsync(DeviceValues.AM43_COMMAND_ID_SET_MOVE, [ DeviceValues.AM43_MOVE_STOP, ]) this.emit("direction", this.direction) this.emit("targetPosition", this.targetPosition) } async updatePositionAsync() { await this.sendCommandAsync(DeviceValues.AM43_COMMAND_ID_GET_POSITION, [ 0x1, ]) } async updateBatteryStatusAsync() { await this.sendCommandAsync( DeviceValues.AM43_COMMAND_ID_GET_BATTERYSTATUS, [0x1] ) } async updateLightSensorAsync() { await this.sendCommandAsync(DeviceValues.AM43_COMMAND_ID_GET_LIGHTSENSOR, [ 0x1, ]) } async sendCommandAsync(commandID: number, data: number[] | Uint8Array) { if (!this.isConnected) await this.connectAsync() this.debugLog("--------Command--------") this.debugLog(`Sending command to device: ${this.id}`) const buffer = buildCommandBuffer(commandID, data) let hexString = buffer.toString("hex") this.debugLog(`Sending command: ${hexString}`) this.debugLog( `Blinds characteristic available: ${ this.blindsControlCharacteristic !== null && this.blindsControlCharacteristic !== undefined }` ) try { await this.blindsControlCharacteristic?.writeAsync(buffer, true) } catch (error) { this.debugLog(`Failed write command with error: ${error}`) } } } export { AM43Device } */