diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index 9ab5af08d6..db8b272d3a 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -58,6 +58,11 @@ zigbeeManufacturer: manufacturer: HEIMAN model: HT-EF-3.0 deviceProfileName: humidity-temp-battery + - id: frient/AQSZB-110 + deviceLabel: Air Quality Sensor + manufacturer: frient A/S + model: AQSZB-110 + deviceProfileName: frient-airquality-humidity-temperature-battery - id: frient/HMSZB-110 deviceLabel: frient Humidity Sensor manufacturer: frient A/S diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml new file mode 100644 index 0000000000..08774c0d31 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml @@ -0,0 +1,52 @@ +name: frient-airquality-humidity-temperature-battery +components: +- id: main + capabilities: + - id: airQualitySensor + version: 1 + - id: tvocMeasurement + version: 1 + - id: tvocHealthConcern + version: 1 + config: + values: + - key: "tvocHealthConcern.value" + enabledValues: + - good + - moderate + - slightlyUnhealthy + - unhealthy + - veryUnhealthy + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 +preferences: + - preferenceId: humidityOffset + explicit: true + - title: "Humidity Sensitivity (%)" + name: humiditySensitivity + description: "Minimum change in humidity level to report" + required: false + preferenceType: number + definition: + minimum: 1 + maximum: 50 + default: 3 + - preferenceId: tempOffset + explicit: true + - title: "Temperature Sensitivity (°)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua index 3a4e495e3e..b040a4f73f 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua @@ -23,7 +23,8 @@ local devices = { FRIENT_HUMIDITY_TEMP_SENSOR = { FINGERPRINTS = { { mfr = "frient A/S", model = "HMSZB-110" }, - { mfr = "frient A/S", model = "HMSZB-120" } + { mfr = "frient A/S", model = "HMSZB-120" }, + { mfr = "frient A/S", model = "AQSZB-110" } }, CONFIGURATION = { { diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/init.lua new file mode 100644 index 0000000000..d05a28ddc2 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/air-quality/init.lua @@ -0,0 +1,165 @@ +local capabilities = require "st.capabilities" +local util = require "st.utils" +local log = require "log" +local data_types = require "st.zigbee.data_types" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement +local HumidityMeasurement = zcl_clusters.RelativeHumidity +local PowerConfiguration = zcl_clusters.PowerConfiguration +local device_management = require "st.zigbee.device_management" +local cluster_base = require "st.zigbee.cluster_base" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local configurationMap = require "configurations" + +local FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS = { + { mfr = "frient A/S", model = "AQSZB-110", subdriver = "airquality" } +} + +local function can_handle_frient(opts, driver, device, ...) + for _, fingerprint in ipairs(FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model and fingerprint.subdriver == "airquality" then + return true + end + end + return false +end + +local Frient_VOCMeasurement = { + ID = 0xFC03, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValue = { ID = 0x0000, base_type = data_types.Uint16 }, + MinMeasuredValue = { ID = 0x0001, base_type = data_types.Uint16 }, + MaxMeasuredValue = { ID = 0x0002, base_type = data_types.Uint16 }, + Resolution = { ID = 0x0003, base_type = data_types.Uint16 }, + }, +} + +Frient_VOCMeasurement.attributes.MeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MinMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MaxMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.Resolution._cluster = Frient_VOCMeasurement + +local MAX_VOC_REPORTABLE_VALUE = 5500 -- Max VOC reportable value + +--- Table to map VOC (ppb) to HealthConcern +local VOC_TO_HEALTHCONCERN_MAPPING = { + [2201] = "veryUnhealthy", + [661] = "unhealthy", + [221] = "slightlyUnhealthy", + [66] = "moderate", + [0] = "good", +} + +--- Map VOC (ppb) to HealthConcern +local function voc_to_healthconcern(raw_voc) + for voc, perc in util.rkeys(VOC_TO_HEALTHCONCERN_MAPPING) do + if raw_voc >= voc then + return perc + end + end +end +--- Map VOC (ppb) to CAQI +local function voc_to_caqi(raw_voc) + if (raw_voc > 5500) then + return 100 + else + return math.floor(raw_voc*99/5500) + end +end + +-- May take around 8 minutes for the first valid VOC measurement to be reported after the device is powered on +local function voc_measure_value_attr_handler(driver, device, attr_val, zb_rx) + local voc_value = attr_val.value + if (voc_value < 65535) then -- ignore it if it's outside the limits + log.trace("Received VOC MeasuredValue :"..util.stringify_table(voc_value)) + voc_value = util.clamp_value(voc_value, 0, MAX_VOC_REPORTABLE_VALUE) + device:emit_event(capabilities.airQualitySensor.airQuality({ value = voc_to_caqi(voc_value)})) + device:emit_event(capabilities.tvocHealthConcern.tvocHealthConcern(voc_to_healthconcern(voc_value))) + device:emit_event(capabilities.tvocMeasurement.tvocLevel({ value = voc_value, unit = "ppb" })) + else + log.warn("Ignoring invalid VOC MeasuredValue : "..util.stringify_table(voc_value)) + end +end + +-- The device sends the value of MeasuredValue to be 0x8000, which corresponds to -327.68C, until it gets the first valid measurement. Therefore we don't emit event before the value is correct. It may take up to 4 minutes +local function temperatureHandler(driver, device, attr_val, zb_rx) + local temp_value = attr_val.value + if (temp_value > -32768) then + log.debug("Received Temperature MeasuredValue :"..util.stringify_table(temp_value)) + device:emit_event(capabilities.temperatureMeasurement.temperature({ value = temp_value / 100, unit = "C" })) + else + log.warn("Ignoring invalid Temperature MeasuredValue : "..util.stringify_table(temp_value)) + end +end + +local function device_init(driver, device) + log.trace "Initializing sensor" + battery_defaults.build_linear_voltage_init(2.3, 3.0)(driver, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end + end +end + +local function device_added(driver, device) + device:emit_event(capabilities.airQualitySensor.airQuality(voc_to_caqi(0))) + device:emit_event(capabilities.tvocHealthConcern.tvocHealthConcern(voc_to_healthconcern(0))) + device:emit_event(capabilities.tvocMeasurement.tvocLevel({ value = 0, unit = "ppb" })) +end + +local function do_refresh(driver, device) + log.trace "Refreshing sensor attributes" + for _, fingerprint in ipairs(FRIENT_AIR_QUALITY_SENSOR_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_VOCMeasurement.ID, Frient_VOCMeasurement.attributes.MeasuredValue.ID, Frient_VOCMeasurement.ManufacturerSpecificCode):to_endpoint(0x26)) + device:send(TemperatureMeasurement.attributes.MeasuredValue:read(device):to_endpoint(0x26)) + device:send(HumidityMeasurement.attributes.MeasuredValue:read(device):to_endpoint(0x26)) + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + end + end +end + +local function do_configure(driver, device) + device:configure() + device:send(device_management.build_bind_request(device, Frient_VOCMeasurement.ID, driver.environment_info.hub_zigbee_eui, 0x26)) + + device:send( + cluster_base.configure_reporting( + device, + data_types.ClusterId(Frient_VOCMeasurement.ID), + Frient_VOCMeasurement.attributes.MeasuredValue.ID, + Frient_VOCMeasurement.attributes.MeasuredValue.base_type.ID, + 60, 600, 10 + ):to_endpoint(0x26) + ) + + device.thread:call_with_delay(5, function() + do_refresh(driver, device) + end) +end + +local frient_airquality_sensor = { + NAME = "frient Air Quality Sensor", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + }, + zigbee_handlers = { + cluster = {}, + attr = { + [Frient_VOCMeasurement.ID] = { + [Frient_VOCMeasurement.attributes.MeasuredValue.ID] = voc_measure_value_attr_handler, + }, + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MeasuredValue.ID] = temperatureHandler, + }, + } + }, + can_handle = can_handle_frient +} + +return frient_airquality_sensor \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua index 7791e6752b..1356345ff2 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/frient-sensor/init.lua @@ -20,7 +20,8 @@ local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement local FRIENT_TEMP_HUMUDITY_SENSOR_FINGERPRINTS = { { mfr = "frient A/S", model = "HMSZB-110" }, - { mfr = "frient A/S", model = "HMSZB-120" } + { mfr = "frient A/S", model = "HMSZB-120" }, + { mfr = "frient A/S", model = "AQSZB-110" } } local function can_handle_frient_sensor(opts, driver, device) @@ -73,6 +74,9 @@ local frient_sensor = { doConfigure = do_configure, infoChanged = info_changed }, + sub_drivers = { + require("frient-sensor/air-quality") + }, can_handle = can_handle_frient_sensor } diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua new file mode 100644 index 0000000000..607a8aa19e --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua @@ -0,0 +1,305 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" + +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local HumidityMeasurement = clusters.RelativeHumidity + +local Frient_VOCMeasurement = { + ID = 0xFC03, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValue = { ID = 0x0000, base_type = data_types.Uint16 }, + MinMeasuredValue = { ID = 0x0001, base_type = data_types.Uint16 }, + MaxMeasuredValue = { ID = 0x0002, base_type = data_types.Uint16 }, + Resolution = { ID = 0x0003, base_type = data_types.Uint16 }, + }, +} + +Frient_VOCMeasurement.attributes.MeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MinMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.MaxMeasuredValue._cluster = Frient_VOCMeasurement +Frient_VOCMeasurement.attributes.Resolution._cluster = Frient_VOCMeasurement + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-airquality-humidity-temperature-battery.yml"), + zigbee_endpoints = { + [0x26] = { + id = 0x26, + manufacturer = "frient A/S", + model = "AQSZB-110", + server_clusters = {0x0001, 0x0402, 0x0405, 0xFC03} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + } + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 0x001E, 0x0E10, 100) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + HumidityMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 60, 3600, 300) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + Frient_VOCMeasurement.ID, + 38 + ):to_endpoint(0x26) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting( + mock_device, + data_types.ClusterId(Frient_VOCMeasurement.ID), + Frient_VOCMeasurement.attributes.MeasuredValue.ID, + Frient_VOCMeasurement.attributes.MeasuredValue.base_type.ID, + 60, 600, 10 + ):to_endpoint(0x26) + }) + + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Humidity report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 0x1950) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 65 })) + } + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2500) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + } + } +) + +test.register_coroutine_test( + "info_changed to check for necessary preferences settings: Temperature Sensitivity", + function() + local updates = { + preferences = { + temperatureSensitivity = 0.9, + humiditySensitivity = 10 + } + } + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + local temperatureSensitivity = math.floor(0.9 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 3600, + temperatureSensitivity + ) + }) + local humiditySensitivity = math.floor(10 * 100 + 0.5) + test.socket.zigbee:__expect_send({ mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 60, + 3600, + humiditySensitivity + ) + }) + test.wait_for_events() + end +) + +test.register_message_test( + "VOC measurement report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, cluster_base.build_test_attr_report(Frient_VOCMeasurement.attributes.MeasuredValue, mock_device, 300) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.airQualitySensor.airQuality({ value = 5 })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tvocHealthConcern.tvocHealthConcern({ value = "slightlyUnhealthy" })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tvocMeasurement.tvocLevel({ value = 300, unit = "ppb" })) + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.run_registered_tests() \ No newline at end of file