UNPKG

homebridge-satellite-fan

Version:

Plugin for homebridge that connects to a Satellite Fan control module: https://www.fan-light.com

527 lines (411 loc) 15 kB
'use strict' const plugin = require('./package'), Noble = require('noble'), EventEmitter = require('events').EventEmitter var Homebridge, Service, Characteristic module.exports = function(homebridge) { console.log("Homebridge API version: " + homebridge.version) Homebridge = homebridge Service = homebridge.hap.Service Characteristic = homebridge.hap.Characteristic homebridge.registerAccessory(plugin.name, "satellite-fan", FanLightAccessory) } function trimAddress(address) { return address.toLowerCase().replace(/:/g, "") } function trimUUID(uuid) { return uuid.toLowerCase().replace(/:/g, "").replace(/-/g, "") } // MARK: - class FanRequest { writeInto(buffer) { throw new TypeError('Must override method') } toPrefixedBuffer(prefix) { var buffer if (prefix > 0) { buffer = new Buffer(13) buffer.writeUInt8(prefix) this.writeInto(buffer.slice(1)) } else { buffer = new Buffer(12) this.writeInto(buffer) } const checksum = buffer.slice(0, buffer.length - 1).reduce(function(a, b){ return a + b }, 0) & 255 buffer.writeUInt8(checksum, buffer.length - 1) return buffer } } class FanGetStateRequest extends FanRequest { writeInto(buffer) { buffer.fill(0) buffer.writeUInt8(160) } } Math.clamp = function(number, min, max) { return Math.max(min, Math.min(number, max)) } class FanUpdateLightRequest extends FanRequest { constructor(isOn, level) { super() this.on = isOn ? 1 : 0 this.level = Math.clamp(level, 0, 100) } writeInto(buffer) { buffer.fill(0) buffer.writeUInt8(161) buffer.writeUInt8(255, 4) buffer.writeUInt8(100, 5) buffer.writeUInt8((this.on << 7) | this.level, 6) buffer.fill(255, 7, 10) } } class FanUpdateLevelRequest extends FanRequest { constructor(level) { super() this.level = Math.clamp(level, 0, 3) } writeInto(buffer) { buffer.fill(0) buffer.writeUInt8(161) buffer.writeUInt8(this.level, 4) buffer.fill(255, 5, 10) } } class FanResponse { static get Keys() { return { FAN_LEVEL: 'fanLevel', FAN_SPEED: 'fanSpeed', LIGHT_ON: 'lightIsOn', LIGHT_BRIGHTNESS: 'lightBrightness' } } static fromPrefixedBuffer(prefix, buffer) { if (prefix > 0) { buffer = buffer.slice(1) } if (buffer.readUInt8(0) != 176) { return null } const response = new FanResponse() const windVelocity = buffer.readUInt8(2) response.fanLevelMaximum = windVelocity & 0b00011111 const currentWindVelocity = buffer.readUInt8(4) response.fanLevel = currentWindVelocity & 0b00011111 const currentBrightness = buffer.readUInt8(6) response.lightIsOn = (currentBrightness & 0b10000000) != 0 response.lightBrightness = (currentBrightness & 0b01111111) return response } get fanSpeed() { return (this.fanLevel / this.fanLevelMaximum) * 100 } } // MARK: - class FanLightAccessory extends EventEmitter { constructor (log, config) { super() this.onDiscover = this.onDiscover.bind(this) this.log = log this.name = config.name || "Ceiling Fan" if (!config.address) { throw new Error(this.prefix + " Missing mandatory config 'address'") } this.address = trimAddress(config.address) if (!config.ble) { throw new Error(this.prefix + " Missing mandatory config 'ble'") } this.manufacturerPrefix = config.ble.prefix || 0 if (!config.ble.serviceUUID) { throw new Error(this.prefix + " Missing mandatory config 'ble.serviceUUID'") } this.serviceUUID = trimUUID(config.ble.serviceUUID) if (!config.ble.writeCharacteristicUUID) { throw new Error(this.prefix + " Missing mandatory config 'ble.writeCharacteristicUUID'") } this.writeCharacteristicUUID = trimUUID(config.ble && config.ble.writeCharacteristicUUID) this.writeCharacteristic = null if (!config.ble.notifyCharacteristicUUID) { throw new Error(this.prefix + " Missing mandatory config 'ble.notifyCharacteristicUUID'") } this.notifyCharacteristicUUID = trimUUID(config.ble && config.ble.notifyCharacteristicUUID) this.notifyCharacteristic = null this.informationService = this.makeInformationService(config) this.fanService = this.makeFanService(config) this.lightService = this.makeLightService(config) this.fanLevelMaximum = 3 Homebridge.on('didFinishLaunching', this.onDidFinishLaunching.bind(this)) } identify (callback) { this.log('Device identified!') callback() } startScanningWithTimeout() { Noble.startScanning([], true) setTimeout(function() { if (Noble.listenerCount('discover') == 0) { return } this.log.debug('Discovery timeout') Noble.stopScanning() }.bind(this), 12500) } stopScanning() { Noble.removeListener('discover', this.onDiscover) if (Noble.listenerCount('discover') == 0) { Noble.removeAllListeners('scanStop') Noble.stopScanning() } } fanSpeedToLevel(value) { return Math.ceil(value * (this.fanLevelMaximum / 100)) } sendCommand(command, callback) { if (!this.notifyCharacteristic || !this.writeCharacteristic) { this.log.info('waiting on connect...') this.once('ready', function() { this.sendCommand(command, callback) }.bind(this)) return } const buffer = command.toPrefixedBuffer(this.manufacturerPrefix) this.log.debug('will send', this.manufacturerPrefix, buffer) this.writeCharacteristic.write(buffer, false, function(error){ if (!error) { this.log.debug('sent') } callback(error) }.bind(this)) } sendUpdateStateRequest() { this.log.info('coalesced update request') const command = new FanGetStateRequest() this.sendCommand(command, function(error){ if (!error) { return } this.emit('updateState', error) }.bind(this)) } // MARK: - onDidFinishLaunching() { this.log.info("Received did finish launching") Noble.on('stateChange', this.onAdapterChange.bind(this)) } onAdapterChange(state) { Noble.removeAllListeners('scanStop') Noble.stopScanning() if (state != 'poweredOn') { this.log.debug("Stopped scanning: " + state) return } this.log.debug('Starting scan') Noble.on('scanStop', function() { setTimeout(function() { this.log.debug('Restart from scan stop') this.startScanningWithTimeout() }.bind(this), 2500) }.bind(this)) Noble.on('discover', this.onDiscover) this.log.debug('discover count ', Noble.listenerCount('discover')) this.startScanningWithTimeout() } onDiscover(peripheral) { if (trimAddress(peripheral.address) !== this.address || (this.writeCharacteristic && this.notifyCharacteristic)) { this.log.debug("Ignoring " + peripheral.address + " (RSSI " + peripheral.rssi + "dB)") return } this.log.debug("Found " + peripheral.address + " (RSSI " + peripheral.rssi + "dB)") this.stopScanning() peripheral.connect(function(error) { this.onConnect(error, peripheral) }.bind(this)) } onConnect(error, peripheral) { if (error) { this.log.error("Connecting to " + peripheral.address + " failed: " + error) this.onDisconnect(error, peripheral) return } this.log.debug("Connected to " + peripheral.address) peripheral.discoverSomeServicesAndCharacteristics([ this.serviceUUID ], [ this.writeCharacteristicUUID, this.notifyCharacteristicUUID ], this.onDiscoverCharacteristics.bind(this)); peripheral.once('disconnect', function(error) { this.onDisconnect(error, peripheral) }.bind(this)) } onDisconnect(error, peripheral) { if (this.writeCharacteristic) { this.writeCharacteristic.removeAllListeners('set') } this.writeCharacteristic = null if (this.notifyCharacteristic) { this.notifyCharacteristic.unsubscribe(null) this.notifyCharacteristic.removeAllListeners('data') } this.notifyCharacteristic = null peripheral.removeAllListeners() this.log.info("Disconnected") this.onDiscover(peripheral) if (this.listenerCount('updateState') != 0) { this.sendUpdateStateRequest() } } onDiscoverCharacteristics(error, services, characteristics) { if (error || characteristics.count < 2) { this.log.error(this.prefix, "Discover services failed: " + error) return } const writeCharacteristic = characteristics[0], notifyCharacteristic = characteristics[1] notifyCharacteristic.on('data', this.onNotify.bind(this)) notifyCharacteristic.subscribe(function (error) { if (error) { this.log.warn("Subscribe to notify characteristic failed") } this.writeCharacteristic = writeCharacteristic this.notifyCharacteristic = notifyCharacteristic this.log.info("Ready") this.emit('ready') }.bind(this)) } onNotify(data, isNotification) { const response = FanResponse.fromPrefixedBuffer(this.manufacturerPrefix, data) if (!response) { return } this.log.debug('received fan state') this.fanLevelMaximum = response.fanLevelMaximum this.emit('updateState', null, response) if (response.fanLevel != 0) { this.fanService.getCharacteristic(Characteristic.On).updateValue(true) this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(response.fanSpeed) } else { this.fanService.getCharacteristic(Characteristic.On).updateValue(false) } if (this.lightService) { this.lightService.getCharacteristic(Characteristic.On).updateValue(response.lightIsOn) this.lightService.getCharacteristic(Characteristic.Brightness).updateValue(response.lightBrightness) } } // MARK: - getNextValueForFanState(key, callback) { const shouldSend = this.listenerCount('updateState') == 0 this.once('updateState', function(error, response) { if (error) { callback(error, null) } else { callback(null, response[key]) } }) if (shouldSend) { this.sendUpdateStateRequest() } else { this.log.debug('Skipping send update') } } enqueueWriteForDependentValue(service, characteristic, produceCommand, callback) { if (!this.notifyCharacteristic || !this.writeCharacteristic) { this.log.debug('Defer write for ready') this.once('ready', function() { this.log.debug('Dequeue write from ready') this.enqueueWriteForDependentValue(service, characteristic, produceCommand, callback) }.bind(this)) return } if (this.listenerCount('updateState') != 0) { this.log.debug('Defer write for update state') this.once('updateState', function() { this.log.debug('Dequeuing write from update state') this.enqueueWriteForDependentValue(service, characteristic, produceCommand, callback) }.bind(this)) return } if (this.writeCharacteristic.listenerCount('write') >= 1) { this.log.debug('Defer write for active write') this.writeCharacteristic.once('write', function() { this.log.debug('Dequeue write from active write') this.enqueueWriteForDependentValue(service, characteristic, produceCommand, callback) }.bind(this)) return } const command = produceCommand(service.getCharacteristic(characteristic).value) this.sendCommand(command, callback) } getFanOn(callback) { this.getNextValueForFanState(FanResponse.Keys.FAN_LEVEL, function(error, level) { callback(error, error ? null : level != 0) }.bind(this)) } setFanOn(newValue, callback) { this.log.info('Fan on: ' + newValue) if (!newValue) { const command = new FanUpdateLevelRequest(0) this.sendCommand(command, callback) return } this.enqueueWriteForDependentValue(this.fanService, Characteristic.RotationSpeed, function(currentSpeed){ const level = this.fanSpeedToLevel(currentSpeed) this.log.debug('Using current level: ' + level) return new FanUpdateLevelRequest(level) }.bind(this), callback) } getFanRotationSpeed(callback) { this.getNextValueForFanState(FanResponse.Keys.FAN_SPEED, callback) } setFanRotationSpeed(newValue, callback) { const level = this.fanSpeedToLevel(newValue) this.log.info('Fan speed: ' + level) const command = new FanUpdateLevelRequest(level) this.sendCommand(command, callback) } getLightOn(callback) { this.getNextValueForFanState(FanResponse.Keys.LIGHT_ON, callback) } setLightOn(newValue, callback) { this.log.info('Light on: ' + newValue) this.enqueueWriteForDependentValue(this.lightService, Characteristic.Brightness, function(currentBrightness) { this.log.debug('Using current brightness: ' + currentBrightness) return new FanUpdateLightRequest(newValue, currentBrightness) }.bind(this), callback) } getLightBrightness(callback) { this.getNextValueForFanState(FanResponse.Keys.LIGHT_BRIGHTNESS, callback) } setLightBrightness(newValue, callback) { this.log.info('Light brightness: ' + newValue) this.enqueueWriteForDependentValue(this.lightService, Characteristic.On, function(currentlyOn) { return new FanUpdateLightRequest(currentlyOn, newValue) }, callback) } // MARK: - makeInformationService(config) { const service = new Service.AccessoryInformation() service .setCharacteristic(Characteristic.Manufacturer, config.device && config.device.manufacturer) .setCharacteristic(Characteristic.Model, config.device && config.device.model) .setCharacteristic(Characteristic.SerialNumber, config.device && config.device.serial) .setCharacteristic(Characteristic.FirmwareRevision, (config.device && config.device.revision) || plugin.version) return service } makeFanService(config) { const service = new Service.Fan(this.name) service.getCharacteristic(Characteristic.On) .on('get', this.getFanOn.bind(this)) .on('set', this.setFanOn.bind(this)) service.getCharacteristic(Characteristic.RotationSpeed) .setProps({ maxValue: 99, minStep: 33 }) .on('get', this.getFanRotationSpeed.bind(this)) .on('set', this.setFanRotationSpeed.bind(this)) return service } makeLightService(config) { if (config.light === false) { return null } const service = new Service.Lightbulb(this.name) service.getCharacteristic(Characteristic.On) .on('get', this.getLightOn.bind(this)) .on('set', this.setLightOn.bind(this)) service.getCharacteristic(Characteristic.Brightness) .on('get', this.getLightBrightness.bind(this)) .on('set', this.setLightBrightness.bind(this)) return service } getServices () { return [ this.informationService, this.fanService, this.lightService ].filter(Boolean) } }