From 5a16508dc27ce39211046c0ea5370697e9fcd041 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 21 Jan 2022 04:37:11 +0300 Subject: [PATCH 1/3] Support MiFlora --- index.js | 10 +- lib/accessory.js | 312 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 290 insertions(+), 32 deletions(-) diff --git a/index.js b/index.js index 48e806b..1e72d08 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,16 @@ module.exports = (homebridge) => { - const { HygrothermographAccessory } = require("./lib/accessory")(homebridge); + const { + HygrothermographAccessory, + MiFloraAccessory, + } = require("./lib/accessory")(homebridge); homebridge.registerAccessory( "homebridge-mi-hygrothermograph", "Hygrotermograph", HygrothermographAccessory ); + homebridge.registerAccessory( + "homebridge-mi-hygrothermograph", + "MiFlora", + MiFloraAccessory + ); }; diff --git a/lib/accessory.js b/lib/accessory.js index 5eaa487..9400378 100644 --- a/lib/accessory.js +++ b/lib/accessory.js @@ -9,32 +9,60 @@ let homebridgeAPI; const defaultTimeout = 15; -class HygrothermographAccessory { - constructor(log, config) { +const AccessoryType = { + Hygrothermograph: "Hygrothermograph", + MiFlora: "MiFlora", +}; + +class BaseAccessory { + constructor(log, config, accessoryType) { this.log = log; this.config = config || {}; + this.accessoryType = accessoryType; this.displayName = this.config.name; - this.latestTemperature = undefined; - this.latestHumidity = undefined; this.latestBatteryLevel = undefined; this.lastUpdatedAt = undefined; this.lastBatchUpdatedAt = undefined; this.informationService = this.getInformationService(); - this.temperatureService = this.getTemperatureService(); - this.humidityService = this.getHumidityService(); this.batteryService = this.getBatteryService(); this.fakeGatoHistoryService = this.getFakeGatoHistoryService(); + this.latestTemperature = undefined; + this.temperatureService = this.getTemperatureService(); this.temperatureMQTTTopic = undefined; - this.humidityMQTTTopic = undefined; this.batteryMQTTTopic = undefined; + switch (this.accessoryType) { + case AccessoryType.Hygrothermograph: + this.latestHumidity = undefined; + + this.humidityService = this.getHumidityService(); + + this.humidityMQTTTopic = undefined; + break; + case AccessoryType.MiFlora: + this.latestIlluminance = undefined; + this.latestFertility = undefined; + this.latestMoisture = undefined; + + this.illuminanceService = this.getIlluminanceService(); + this.fertilityService = this.getFertilityService(); + this.moistureService = this.getMoistureService(); + + this.illuminanceMQTTTopic = undefined; + this.fertilityMQTTTopic = undefined; + this.moistureMQTTTopic = undefined; + break; + default: + break; + } + this.mqttClient = this.setupMQTTClient(); this.scanner = this.setupScanner(); - this.log.debug("Initialized accessory"); + this.log.debug(`Initialized accessory of type ${this.accessoryType}`); } setTemperature(newValue, force = false) { @@ -60,6 +88,86 @@ class HygrothermographAccessory { return this.latestTemperature + this.temperatureOffset; } + setIlluminance(newValue, force = false) { + const MinMaxIlluminanceValues = { min: 0.0001, max: 100000 }; + if (newValue == null) { + return; + } + // Validate values according to https://developers.homebridge.io/#/characteristic/CurrentAmbientLightLevel + newValue = + newValue > MinMaxIlluminanceValues.max + ? MinMaxIlluminanceValues.max + : newValue; + newValue = + newValue < MinMaxIlluminanceValues.min + ? MinMaxIlluminanceValues.min + : newValue; + this.latestIlluminance = newValue; + this.lastUpdatedAt = Date.now(); + if (this.useBatchUpdating && force === false) { + return; + } + this.illuminanceService + .getCharacteristic(Characteristic.CurrentAmbientLightLevel) + .updateValue(newValue); + this.addFakeGatoHistoryEntry(); + this.publishValueToMQTT(this.illuminanceMQTTTopic, this.illuminance); + } + + get illuminance() { + if (this.hasTimedOut() || this.latestIlluminance == null) { + return; + } + return this.latestIlluminance + this.temperatureOffset; + } + + setMoisture(newValue, force = false) { + if (newValue == null) { + return; + } + this.latestMoisture = newValue; + this.lastUpdatedAt = Date.now(); + if (this.useBatchUpdating && force === false) { + return; + } + this.moistureService + .getCharacteristic(Characteristic.CurrentRelativeHumidity) + .updateValue(newValue); + this.addFakeGatoHistoryEntry(); + this.publishValueToMQTT(this.moistureMQTTTopic, this.moisture); + } + + get moisture() { + if (this.hasTimedOut() || this.latestMoisture == null) { + return; + } + return this.latestMoisture + this.moistureOffset; + } + + setFertility(newValue, force = false) { + if (newValue == null) { + return; + } + this.latestFertility = newValue; + this.lastUpdatedAt = Date.now(); + if (this.useBatchUpdating && force === false) { + return; + } + // TODO: Fertility Accessory and Characteristic + this.fertilityService + .getCharacteristic(Characteristic.CurrentAmbientLightLevel) + .updateValue(newValue); + this.addFakeGatoHistoryEntry(); + this.publishValueToMQTT(this.fertilityMQTTTopic, this.fertility); + } + + get fertility() { + if (this.hasTimedOut() || this.latestFertility == null) { + return; + } + return this.latestFertility + this.fertilityOffset; + } + setHumidity(newValue, force = false) { if (newValue == null) { return; @@ -131,6 +239,18 @@ class HygrothermographAccessory { return this.config.humidityName || "Humidity"; } + get illuminanceName() { + return this.config.illuminanceName || "Illuminance"; + } + + get moistureName() { + return this.config.moistureName || "Moisture"; + } + + get fertilityName() { + return this.config.fertilityName || "Fertility"; + } + get serialNumber() { return this.config.address != null ? this.config.address.replace(/:/g, "") @@ -165,6 +285,14 @@ class HygrothermographAccessory { return this.config.humidityOffset || 0; } + get moistureOffset() { + return this.config.moistureOffset || 0; + } + + get fertilityOffset() { + return this.config.fertilityOffset || 0; + } + get isBatteryLevelDisabled() { return this.config.disableBatteryLevel || false; } @@ -200,11 +328,34 @@ class HygrothermographAccessory { this.log.debug(`[${address || id}] Temperature: ${temperature}C`); this.setTemperature(temperature); }); - scanner.on("humidityChange", (humidity, peripheral) => { - const { address, id } = peripheral; - this.log.debug(`[${address || id}] Humidity: ${humidity}%`); - this.setHumidity(humidity); - }); + switch (this.accessoryType) { + case AccessoryType.Hygrothermograph: + scanner.on("humidityChange", (humidity, peripheral) => { + const { address, id } = peripheral; + this.log.debug(`[${address || id}] Humidity: ${humidity}%`); + this.setHumidity(humidity); + }); + break; + case AccessoryType.MiFlora: + scanner.on("moistureChange", (moisture, peripheral) => { + const { address, id } = peripheral; + this.log.debug(`[${address || id}] Moisture: ${moisture}%`); + this.setMoisture(moisture); + }); + scanner.on("fertilityChange", (fertility, peripheral) => { + const { address, id } = peripheral; + this.log.debug(`[${address || id}] Fertility: ${fertility} μS/cm`); + this.setFertility(fertility); + }); + scanner.on("illuminanceChange", (illuminance, peripheral) => { + const { address, id } = peripheral; + this.log.debug(`[${address || id}] Illuminance: ${illuminance} lux`); + this.setIlluminance(illuminance); + }); + break; + default: + break; + } scanner.on("batteryChange", (batteryLevel, peripheral) => { const { address, id } = peripheral; this.log.debug(`[${address || id}] Battery level: ${batteryLevel}%`); @@ -216,8 +367,19 @@ class HygrothermographAccessory { } this.log.debug("Batch updating values"); this.lastBatchUpdatedAt = Date.now(); + switch (this.accessoryType) { + case AccessoryType.Hygrothermograph: + this.setHumidity(this.humidity, true); + break; + case AccessoryType.MiFlora: + this.setMoisture(this.moisture, true); + this.setFertility(this.fertility, true); + this.setIlluminance(this.illuminance, true); + break; + default: + break; + } this.setTemperature(this.temperature, true); - this.setHumidity(this.humidity, true); this.setBatteryLevel(this.batteryLevel, true); }); scanner.on("error", (error) => { @@ -264,19 +426,42 @@ class HygrothermographAccessory { if (config == null || config.url == null) { return; } - const { - temperatureTopic, - humidityTopic, - batteryTopic, - url, - ...mqttOptions - } = config; - this.temperatureMQTTTopic = temperatureTopic; - this.humidityMQTTTopic = humidityTopic; - this.batteryMQTTTopic = batteryTopic; + let client; + if (this.accessoryType === AccessoryType.Hygrothermograph) { + const { + temperatureTopic, + humidityTopic, + batteryTopic, + url, + ...mqttOptions + } = config; + + this.humidityMQTTTopic = humidityTopic; + this.temperatureMQTTTopic = temperatureTopic; + this.batteryMQTTTopic = batteryTopic; + + client = mqtt.connect(url, mqttOptions); + } else if (this.accessoryType === AccessoryType.MiFlora) { + const { + temperatureTopic, + illuminanceTopic, + moistureTopic, + fertilityTopic, + batteryTopic, + url, + ...mqttOptions + } = config; + + this.illuminanceMQTTTopic = undefined; + this.fertilityMQTTTopic = undefined; + this.moistureMQTTTopic = undefined; + this.temperatureMQTTTopic = temperatureTopic; + this.batteryMQTTTopic = batteryTopic; + + client = mqtt.connect(url, mqttOptions); + } - const client = mqtt.connect(url, mqttOptions); client.on("connect", () => { this.log.info("MQTT Client connected."); }); @@ -324,9 +509,23 @@ class HygrothermographAccessory { } getInformationService() { + let manufacturer; + let model; + switch (this.accessoryType) { + case AccessoryType.Hygrothermograph: + manufacturer = "Cleargrass Inc"; + model = "LYWSDCGQ01ZM"; + break; + case AccessoryType.MiFlora: + manufacturer = "HHCC Plant Technology Co., Ltd"; + model = "HHCCJCY01HHCC"; + break; + default: + break; + } const accessoryInformation = new Service.AccessoryInformation() - .setCharacteristic(Characteristic.Manufacturer, "Cleargrass Inc") - .setCharacteristic(Characteristic.Model, "LYWSDCGQ01ZM") + .setCharacteristic(Characteristic.Manufacturer, manufacturer) + .setCharacteristic(Characteristic.Model, model) .setCharacteristic(Characteristic.FirmwareRevision, version); if (this.serialNumber != null) { accessoryInformation.setCharacteristic( @@ -370,6 +569,30 @@ class HygrothermographAccessory { return humidityService; } + getMoistureService() { + const moistureService = new Service.HumiditySensor(this.moistureName); + moistureService + .getCharacteristic(Characteristic.CurrentRelativeHumidity) + .on("get", this.onCharacteristicGetValue.bind(this, "moisture")); + return moistureService; + } + + getIlluminanceService() { + const illuminanceService = new Service.LightSensor(this.illuminanceName); + illuminanceService + .getCharacteristic(Characteristic.CurrentAmbientLightLevel) + .on("get", this.onCharacteristicGetValue.bind(this, "illuminance")); + return illuminanceService; + } + + getFertilityService() { + const fertilityService = new Service.LightSensor(this.fertilityName); + fertilityService + .getCharacteristic(Characteristic.CurrentAmbientLightLevel) + .on("get", this.onCharacteristicGetValue.bind(this, "fertility")); + return fertilityService; + } + getBatteryService() { if (this.isBatteryLevelDisabled) { return; @@ -389,21 +612,48 @@ class HygrothermographAccessory { } getServices() { - const services = [ + let services = [ this.informationService, - this.temperatureService, - this.humidityService, this.batteryService, this.fakeGatoHistoryService, ]; + switch (this.accessoryType) { + case AccessoryType.Hygrothermograph: + services.push(this.temperatureService, this.humidityService); + break; + case AccessoryType.MiFlora: + services.push( + this.moistureService, + this.illuminanceService, + this.temperatureService, + this.fertilityService + ); + break; + default: + throw Error( + `getServices not implemented for Accessory "${this.accessoryType}"` + ); + } return services.filter(Boolean); } } +class MiFloraAccessory extends BaseAccessory { + constructor(log, config) { + super(log, config, AccessoryType.MiFlora); + } +} + +class HygrothermographAccessory extends BaseAccessory { + constructor(log, config) { + super(log, config, AccessoryType.Hygrothermograph); + } +} + module.exports = (homebridge) => { FakeGatoHistoryService = require("fakegato-history")(homebridge); Service = homebridge.hap.Service; Characteristic = homebridge.hap.Characteristic; homebridgeAPI = homebridge; - return { HygrothermographAccessory }; + return { HygrothermographAccessory, MiFloraAccessory }; }; From 754fbcdae41ff84c505c19b8a9c2b2315dbb83b6 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 21 Jan 2022 04:37:21 +0300 Subject: [PATCH 2/3] Tests --- test/accessory.test.js | 142 ++++++++++++++++++++++++++++++++++++++--- test/index.test.js | 11 +++- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/test/accessory.test.js b/test/accessory.test.js index 9b3c461..dca158b 100644 --- a/test/accessory.test.js +++ b/test/accessory.test.js @@ -27,12 +27,14 @@ describe("accessory", () => { SerialNumber: new CharacteristicMock(), CurrentTemperature: new CharacteristicMock(), CurrentRelativeHumidity: new CharacteristicMock(), + CurrentAmbientLightLevel: new CharacteristicMock(), }; this.services = { BatteryService: ServiceMock, HumiditySensor: ServiceMock, TemperatureSensor: ServiceMock, + LightSensor: ServiceMock, AccessoryInformation: ServiceMock, }; @@ -46,15 +48,19 @@ describe("accessory", () => { }, }; - const { HygrothermographAccessory } = proxyquire("../lib/accessory", { - "./scanner": { - Scanner, - }, - "fakegato-history": () => FakeGatoHistoryServiceMock, - mqtt: mqttMock, - })(this.homebridgeMock); + const { HygrothermographAccessory, MiFloraAccessory } = proxyquire( + "../lib/accessory", + { + "./scanner": { + Scanner, + }, + "fakegato-history": () => FakeGatoHistoryServiceMock, + mqtt: mqttMock, + } + )(this.homebridgeMock); this.HygrothermographAccessory = HygrothermographAccessory; + this.MiFloraAccessory = MiFloraAccessory; }); afterEach(() => { @@ -120,6 +126,75 @@ describe("accessory", () => { }); }); + it("should update current fertility", () => { + const accessory = new this.MiFloraAccessory(mockLogger, {}); + accessory.scanner.emit("fertilityChange", 500, { + address: "123", + id: "123", + }); + assert.strictEqual(accessory.latestFertility, 500); + accessory.scanner.emit("fertilityChange", 500, { + address: "123", + id: "123", + }); + assert.strictEqual(accessory.latestFertility, 500); + accessory.scanner.emit("fertilityChange", 500, { + id: "123", + }); + }); + + it("should update current moisture", () => { + const accessory = new this.MiFloraAccessory(mockLogger, {}); + const characteristic = this.characteristics.CurrentRelativeHumidity; + const updateValueSpy = sinon.spy(characteristic, "updateValue"); + accessory.scanner.emit("moistureChange", 50.7, { + address: "123", + id: "123", + }); + assert.strictEqual(accessory.latestMoisture, 50.7); + accessory.scanner.emit("moistureChange", 50.7, { + address: "123", + id: "123", + }); + assert.strictEqual(accessory.latestMoisture, 50.7); + assert(updateValueSpy.called); + accessory.scanner.emit("moistureChange", 50.7, { + id: "123", + }); + }); + + it("should update current illuminance", () => { + const accessory = new this.MiFloraAccessory(mockLogger, {}); + const characteristic = this.characteristics.CurrentAmbientLightLevel; + const updateValueSpy = sinon.spy(characteristic, "updateValue"); + accessory.scanner.emit("illuminanceChange", 3000, { + address: "123", + id: "123", + }); + assert.strictEqual(accessory.latestIlluminance, 3000); + accessory.scanner.emit("illuminanceChange", 3000, { + address: "123", + id: "123", + }); + assert.strictEqual(accessory.latestIlluminance, 3000); + assert(updateValueSpy.called); + accessory.scanner.emit("illuminanceChange", 3000, { + id: "123", + }); + }); + + it("should not update current fertility on non-MiFlora", () => { + const accessory = new this.HygrothermographAccessory(mockLogger, {}); + const characteristic = this.characteristics.CurrentRelativeHumidity; + const updateValueSpy = sinon.spy(characteristic, "updateValue"); + accessory.scanner.emit("fertilityChange", 500, { + address: "123", + id: "123", + }); + assert.strictEqual(accessory.latestFertility, undefined); + assert(!updateValueSpy.called); + }); + it("should not update humidity characteristic when using update interval", () => { const accessory = new this.HygrothermographAccessory(mockLogger, { updateInterval: 60, @@ -296,12 +371,18 @@ describe("accessory", () => { assert(batterySpy.calledWith(sinon.match.instanceOf(Error))); }); - it("should return all services", () => { + it("should return all services for Hygrothermograph", () => { const accessory = new this.HygrothermographAccessory(mockLogger, {}); const services = accessory.getServices(); assert.strictEqual(services.length, 4); }); + it("should return all services for MiFlora", () => { + const accessory = new this.MiFloraAccessory(mockLogger, {}); + const services = accessory.getServices(); + assert.strictEqual(services.length, 6); + }); + it("should set address config", () => { const config = { address: "deadbeef" }; const accessory = new this.HygrothermographAccessory(mockLogger, config); @@ -629,6 +710,51 @@ describe("accessory", () => { assert(updateBatteryValueSpy.called === false); }); + it("should batch update on change when configured with updateInterval [MiFlora]", () => { + const accessory = new this.MiFloraAccessory(mockLogger); + accessory.scanner.emit("temperatureChange", 25.5, { + address: "123", + id: "123", + }); + accessory.scanner.emit("moistureChange", 35.5, { + address: "123", + id: "123", + }); + accessory.scanner.emit("fertilityChange", 500, { + address: "123", + id: "123", + }); + accessory.scanner.emit("illuminanceChange", 400, { + address: "123", + id: "123", + }); + const updateTemperatureValueSpy = sinon.spy( + this.characteristics.CurrentTemperature, + "updateValue" + ); + const updateMoistureValueSpy = sinon.spy( + this.characteristics.CurrentRelativeHumidity, + "updateValue" + ); + const updateFertilityValueSpy = sinon.spy( + this.characteristics.BatteryLevel, + "updateValue" + ); + const updateIlluminanceValueSpy = sinon.spy( + this.characteristics.CurrentAmbientLightLevel, + "updateValue" + ); + + accessory.scanner.emit("change", { + address: "123", + id: "123", + }); + assert(updateTemperatureValueSpy.called === false); + assert(updateMoistureValueSpy.called === false); + assert(updateFertilityValueSpy.called === false); + assert(updateIlluminanceValueSpy.called === false); + }); + it("should not batch update on change when configured with updateInterval", () => { const accessory = new this.HygrothermographAccessory(mockLogger, { updateInterval: 60, diff --git a/test/index.test.js b/test/index.test.js index d2080b0..7f6bfdc 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -11,17 +11,26 @@ describe("index", () => { it("should export accessory", () => { const registerStub = sinon.stub(); const accessoryStub = sinon.stub(); + const miFloraAccessoryStub = sinon.stub(); const HomebridgeMock = { registerAccessory: registerStub, }; proxyquire("../index", { - "./lib/accessory": () => ({ HygrothermographAccessory: accessoryStub }), + "./lib/accessory": () => ({ + HygrothermographAccessory: accessoryStub, + MiFloraAccessory: miFloraAccessoryStub, + }), })(HomebridgeMock); assert( registerStub.calledWith( "homebridge-mi-hygrothermograph", "Hygrotermograph", accessoryStub + ), + registerStub.calledWith( + "homebridge-mi-hygrothermograph", + "MiFlora", + miFloraAccessoryStub ) ); }); From 8c261f85bdeaf67d07bf2ca5ff6c13cf422a2fe1 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 21 Jan 2022 04:49:47 +0300 Subject: [PATCH 3/3] Fix duplicate acessory --- lib/accessory.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/accessory.js b/lib/accessory.js index 9400378..58a924a 100644 --- a/lib/accessory.js +++ b/lib/accessory.js @@ -586,7 +586,10 @@ class BaseAccessory { } getFertilityService() { - const fertilityService = new Service.LightSensor(this.fertilityName); + const fertilityService = new Service.LightSensor( + this.fertilityName, + "customFertility" + ); fertilityService .getCharacteristic(Characteristic.CurrentAmbientLightLevel) .on("get", this.onCharacteristicGetValue.bind(this, "fertility"));