From c1f31023a200b2e22f9da89674a81434cf45e205 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Fri, 21 Aug 2026 11:59:33 +0800 Subject: [PATCH 1/4] feat(zigbee): add Rti-Tek STHZB humidity sensor --- .../zigbee-humidity-sensor/fingerprints.yml | 7 +- .../profiles/rtitek-sthzb.yml | 61 +++++++++ .../src/test/test_rtitek_sthzb.lua | 122 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index ee36659670..461f73599b 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -1,4 +1,9 @@ zigbeeManufacturer: + - id: "Rti-Tek/STHZB" + deviceLabel: Rti-Tek STHZB + manufacturer: Rti-Tek + model: STHZB + deviceProfileName: rtitek-sthzb - id: "LUMI/lumi.sensor_ht.agl02" deviceLabel: Aqara Temperature and Humidity Sensor T1 manufacturer: LUMI @@ -153,4 +158,4 @@ zigbeeGeneric: - 0x0B04 client: - 0x0003 - deviceProfileName: humidity-battery \ No newline at end of file + deviceProfileName: humidity-battery diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml new file mode 100644 index 0000000000..040ac19210 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml @@ -0,0 +1,61 @@ +name: rtitek-sthzb +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: TempHumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + actions: [] diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua new file mode 100644 index 0000000000..96b4f2cd31 --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua @@ -0,0 +1,122 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local PowerConfiguration = clusters.PowerConfiguration +local RelativeHumidity = clusters.RelativeHumidity +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("rtitek-sthzb.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Rti-Tek", + model = "STHZB", + server_clusters = { 0x0001, 0x0402, 0x0405, 0xFD22 }, + }, + }, +}) + +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( + "STHZB standard temperature report is exposed in Celsius", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2630), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", capabilities.temperatureMeasurement.temperature({ value = 26.3, unit = "C" }) + ), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "STHZB standard humidity report is exposed as percent", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + RelativeHumidity.attributes.MeasuredValue:build_test_attr_report(mock_device, 5830), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", capabilities.relativeHumidityMeasurement.humidity({ value = 58.3 }) + ), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "STHZB standard battery percentage is exposed", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 150), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(75)), + }, + }, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "STHZB refresh reads standard measurement attributes", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + RelativeHumidity.attributes.MeasuredValue:read(mock_device), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device), + }) + test.wait_for_events() + end, + { min_api_version = 14, inner_block_ordering = "relaxed" } +) + +test.run_registered_tests() From 562038f92baee1824a7dbd25246e5c26390262ae Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Mon, 24 Aug 2026 15:05:15 +0800 Subject: [PATCH 2/4] docs: add Rti-Tek STHZB WWST design --- .../2026-08-24-rtitek-sthzb-wwst-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md diff --git a/docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md b/docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md new file mode 100644 index 0000000000..cbef3b63e5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md @@ -0,0 +1,96 @@ +# Rti-Tek STHZB WWST Driver Design + +## Goal + +Add WWST-compliant support for the Rti-Tek STHZB Zigbee temperature and +humidity sensor to the official `zigbee-humidity-sensor` Edge driver. The +implementation must use only SmartThings standard capabilities and device +preferences, while retaining the device features that can be represented by +those public interfaces. + +This work is isolated to the `codex/rtitek-sthzb-wwst` branch and does not +change the separately distributed `Rti-Tek STHZB Unit Enum8 v15` driver. + +## Scope + +The driver matches `manufacturer: Rti-Tek` and `model: STHZB` and uses the +existing `rtitek-sthzb` profile in `drivers/SmartThings/zigbee-humidity-sensor`. + +The user-facing device card exposes only standard capabilities: + +- `temperatureMeasurement` +- `relativeHumidityMeasurement` +- `battery` +- `refresh` +- `temperatureAlarm` + +The device preferences provide the configurable manufacturer-specific +behavior: + +| Preference | Device attribute | Wire type and conversion | +| --- | --- | --- | +| Display temperature unit | `0xFD22/0x0000` | `Enum8`: Celsius `0`, Fahrenheit `1` | +| Temperature calibration | `0xFD22/0xE005` | `Int8`, preference value in `0.1 C` | +| Humidity calibration | `0xFD22/0xE006` | `Int8`, preference value in `0.1 %RH` | +| Temperature alarm upper limit | `0xFD22/0xE00A` | `Int16`, preference value in `0.1 C`, written as `value * 100` | +| Temperature alarm lower limit | `0xFD22/0xE00B` | `Int16`, preference value in `0.1 C`, written as `value * 100` | +| Humidity alarm upper limit | `0xFD22/0xE00C` | `Uint16`, whole `%RH`, written as `value * 100` | +| Humidity alarm lower limit | `0xFD22/0xE00D` | `Uint16`, whole `%RH`, written as `value * 100` | + +`0xFD22/0xE00E` maps to the standard `temperatureAlarm` capability: + +- normal -> `cleared` +- low temperature alarm -> `freeze` +- high temperature alarm -> `heat` + +`0xFD22/0xE00F` is logged for diagnosis only. SmartThings has no suitable +standard capability for a separate humidity alarm state. The thresholds remain +configurable and users can create routines from standard humidity measurements. + +The driver retains diagnostic logging of Zigbee LQI and RSSI, but does not +surface either as a custom user-facing capability. The driver uses no Custom +Capabilities. + +## Data Flow and Validation + +On installation and refresh, the sub-driver reads standard measurements and +all relevant `0xFD22` attributes. Attribute reports update standard events, +persisted field values, and preference synchronization as appropriate. + +When a setting changes, the lifecycle handler normalizes to device-supported +precision before sending a write. It validates paired alarm limits using the +latest cached value. If a proposed upper limit is less than or equal to the +lower limit, it is clamped to one device step above the lower limit; conversely, +a proposed lower limit is clamped to one device step below the upper limit. +The saved preference is synchronized with that effective value so the app does +not retain an invalid value. + +Temperature settings use `0.1 C` increments. Humidity alarm settings use +whole-percent increments because the device accepts that precision. Unit +selection is written as a device setting; standard temperature measurement +continues to be emitted in Celsius and SmartThings performs presentation-unit +conversion. + +## Implementation Boundaries + +- Add a small `rtitek-sthzb` sub-driver with matching, lifecycle, report, and + preference-write handlers. +- Update only the Rti-Tek profile, the parent sub-driver registration, and + Rti-Tek tests in the official humidity-sensor package. +- Reuse the existing parent driver behavior for standard clusters unless the + device-specific sub-driver must override it. +- Do not add a top-level driver package, Custom Capability definitions, OTA + support, LQI/RSSI UI, or a separate humidity alarm card. + +## Verification + +Automated coverage must verify fingerprint matching, standard temperature, +humidity, and battery reports, refresh reads, each preference-to-FD22 write +conversion, paired-limit clamping, and all temperature-alarm mappings. + +After local tests pass, package the official driver on a separate development +channel and assign it to the test hub. Validate pairing or driver reassignment, +reporting, refresh, every preference write and readback, invalid paired-limit +handling, temperature alarm transitions, and absence of Custom Capabilities in +the generated device profile. This local validation completes before any PR or +Console certification submission is advanced. From 5f5b4293678aada9194731b3da3c5ec2cc64c256 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Fri, 28 Aug 2026 09:47:19 +0800 Subject: [PATCH 3/4] feat(zigbee): add Rti-Tek STHZB WWST support --- .../2026-08-24-rtitek-sthzb-wwst-design.md | 5 +- .../profiles/rtitek-sthzb.yml | 80 +++- .../zigbee-humidity-sensor/src/init.lua | 4 +- .../src/rtitek-sthzb/can_handle.lua | 11 + .../src/rtitek-sthzb/init.lua | 406 ++++++++++++++++++ .../src/sub_drivers.lua | 1 + .../src/test/test_rtitek_sthzb.lua | 197 ++++++++- 7 files changed, 688 insertions(+), 16 deletions(-) create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua diff --git a/docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md b/docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md index cbef3b63e5..31f540666e 100644 --- a/docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md +++ b/docs/superpowers/specs/2026-08-24-rtitek-sthzb-wwst-design.md @@ -62,8 +62,9 @@ precision before sending a write. It validates paired alarm limits using the latest cached value. If a proposed upper limit is less than or equal to the lower limit, it is clamped to one device step above the lower limit; conversely, a proposed lower limit is clamped to one device step below the upper limit. -The saved preference is synchronized with that effective value so the app does -not retain an invalid value. +The effective raw value is cached and read back from the device. Edge drivers +cannot overwrite the app-owned preference value, so the Settings input may +continue to display the submitted value after a paired-limit clamp. Temperature settings use `0.1 C` increments. Humidity alarm settings use whole-percent increments because the device accepts that precision. Unit diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml index 040ac19210..ffcf936966 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/rtitek-sthzb.yml @@ -8,15 +8,84 @@ components: version: 1 - id: battery version: 1 + - id: temperatureAlarm + version: 1 + config: + values: + - key: "temperatureAlarm.value" + enabledValues: + - freeze + - heat + - cleared - id: refresh version: 1 categories: - name: TempHumiditySensor preferences: - - preferenceId: tempOffset - explicit: true - - preferenceId: humidityOffset - explicit: true + - title: "Display temperature unit" + name: temperatureUnit + description: "Temperature unit used by the device display" + required: false + preferenceType: enumeration + definition: + options: + "0": "Celsius" + "1": "Fahrenheit" + default: "0" + - title: "Temperature calibration (C)" + name: temperatureCalibration + description: "Device measurement adjustment" + required: false + preferenceType: number + definition: + minimum: -10.0 + maximum: 10.0 + default: 0.0 + - title: "Humidity calibration (%)" + name: humidityCalibration + description: "Device measurement adjustment" + required: false + preferenceType: number + definition: + minimum: -10.0 + maximum: 10.0 + default: 0.0 + - title: "Temperature alarm upper limit (C)" + name: temperatureAlarmUpper + description: "Upper temperature alarm threshold" + required: false + preferenceType: number + definition: + minimum: -30.0 + maximum: 60.0 + default: 26.0 + - title: "Temperature alarm lower limit (C)" + name: temperatureAlarmLower + description: "Lower temperature alarm threshold" + required: false + preferenceType: number + definition: + minimum: -30.0 + maximum: 60.0 + default: 20.0 + - title: "Humidity alarm upper limit (%)" + name: humidityAlarmUpper + description: "Upper humidity alarm threshold" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 60 + - title: "Humidity alarm lower limit (%)" + name: humidityAlarmLower + description: "Lower humidity alarm threshold" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 30 deviceConfig: dashboard: states: @@ -44,6 +113,9 @@ deviceConfig: - component: main capability: battery version: 1 + - component: main + capability: temperatureAlarm + version: 1 - component: main capability: refresh version: 1 diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua index 6bde23ed25..4585678136 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua @@ -54,7 +54,9 @@ local zigbee_humidity_driver = { supported_capabilities = { capabilities.battery, capabilities.relativeHumidityMeasurement, - capabilities.temperatureMeasurement + capabilities.temperatureMeasurement, + capabilities.temperatureAlarm, + capabilities.refresh, }, zigbee_handlers = { attr = { diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/can_handle.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/can_handle.lua new file mode 100644 index 0000000000..4218ab9ffc --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_rtitek_sthzb(opts, driver, device) + if device:get_manufacturer() == "Rti-Tek" and device:get_model() == "STHZB" then + return true, require("rtitek-sthzb") + end + return false +end + +return can_handle_rtitek_sthzb diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua new file mode 100644 index 0000000000..f8c7971bff --- /dev/null +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua @@ -0,0 +1,406 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local device_management = require "st.zigbee.device_management" +local generic_body = require "st.zigbee.generic_body" +local global_commands = require "st.zigbee.zcl.global_commands" +local log = require "log" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" + +local PowerConfiguration = clusters.PowerConfiguration +local RelativeHumidity = clusters.RelativeHumidity +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local RTI_TEK_PRIVATE_CLUSTER = 0xFD22 +local TEMPERATURE_UNIT_ATTR = 0x0000 +local FAULT_CODE_ATTR = 0x0002 +local TEMPERATURE_CALIBRATION_ATTR = 0xE005 +local HUMIDITY_CALIBRATION_ATTR = 0xE006 +local TEMPERATURE_ALARM_UPPER_ATTR = 0xE00A +local TEMPERATURE_ALARM_LOWER_ATTR = 0xE00B +local HUMIDITY_ALARM_UPPER_ATTR = 0xE00C +local HUMIDITY_ALARM_LOWER_ATTR = 0xE00D +local TEMPERATURE_ALARM_STATUS_ATTR = 0xE00E +local HUMIDITY_ALARM_STATUS_ATTR = 0xE00F + +local PRIVATE_ATTRIBUTES = { + TEMPERATURE_UNIT_ATTR, + FAULT_CODE_ATTR, + TEMPERATURE_CALIBRATION_ATTR, + HUMIDITY_CALIBRATION_ATTR, + TEMPERATURE_ALARM_UPPER_ATTR, + TEMPERATURE_ALARM_LOWER_ATTR, + HUMIDITY_ALARM_UPPER_ATTR, + HUMIDITY_ALARM_LOWER_ATTR, + TEMPERATURE_ALARM_STATUS_ATTR, + HUMIDITY_ALARM_STATUS_ATTR, +} + +local DEFAULT_RAW_VALUES = { + temperatureCalibration = 0, + humidityCalibration = 0, + temperatureAlarmUpper = 2600, + temperatureAlarmLower = 2000, + humidityAlarmUpper = 6000, + humidityAlarmLower = 3000, +} + +local PREFERENCE_FIELDS = { + temperatureCalibration = { + attr = TEMPERATURE_CALIBRATION_ATTR, + data_type = data_types.Int8, + field = "rtitek_temperature_calibration", + minimum = -10, + maximum = 10, + scale = 10, + step = 0.1, + }, + humidityCalibration = { + attr = HUMIDITY_CALIBRATION_ATTR, + data_type = data_types.Int8, + field = "rtitek_humidity_calibration", + minimum = -10, + maximum = 10, + scale = 10, + step = 0.1, + }, + temperatureAlarmUpper = { + attr = TEMPERATURE_ALARM_UPPER_ATTR, + data_type = data_types.Int16, + field = "rtitek_temperature_alarm_upper", + paired_field = "rtitek_temperature_alarm_lower", + minimum = -30, + maximum = 60, + scale = 100, + step = 0.1, + gap = 10, + is_upper = true, + }, + temperatureAlarmLower = { + attr = TEMPERATURE_ALARM_LOWER_ATTR, + data_type = data_types.Int16, + field = "rtitek_temperature_alarm_lower", + paired_field = "rtitek_temperature_alarm_upper", + minimum = -30, + maximum = 60, + scale = 100, + step = 0.1, + gap = 10, + is_upper = false, + }, + humidityAlarmUpper = { + attr = HUMIDITY_ALARM_UPPER_ATTR, + data_type = data_types.Uint16, + field = "rtitek_humidity_alarm_upper", + paired_field = "rtitek_humidity_alarm_lower", + minimum = 0, + maximum = 100, + scale = 100, + step = 1, + gap = 100, + is_upper = true, + }, + humidityAlarmLower = { + attr = HUMIDITY_ALARM_LOWER_ATTR, + data_type = data_types.Uint16, + field = "rtitek_humidity_alarm_lower", + paired_field = "rtitek_humidity_alarm_upper", + minimum = 0, + maximum = 100, + scale = 100, + step = 1, + gap = 100, + is_upper = false, + }, +} + +local function private_endpoint(device) + return device:get_endpoint(RTI_TEK_PRIVATE_CLUSTER) or 0x01 +end + +local function round_to_step(value, step) + local scaled = value / step + if scaled >= 0 then + return math.floor(scaled + 0.5) * step + end + return math.ceil(scaled - 0.5) * step +end + +local function raw_value(value, setting) + value = math.max(setting.minimum, math.min(setting.maximum, value)) + local raw = round_to_step(value, setting.step) * setting.scale + if raw >= 0 then + return math.floor(raw + 0.5) + end + return math.ceil(raw - 0.5) +end + +local function clamp_paired_limit(raw, paired_raw, is_upper, gap) + if paired_raw == nil then + return raw + end + if is_upper and raw <= paired_raw then + return paired_raw + gap + end + if not is_upper and raw >= paired_raw then + return paired_raw - gap + end + return raw +end + +local function send_private_read(device, attr_id) + local payload = string.char(attr_id & 0xFF, (attr_id >> 8) & 0xFF) + local header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(global_commands.ReadAttribute.ID), + }) + local address = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + private_endpoint(device), + zb_const.HA_PROFILE_ID, + RTI_TEK_PRIVATE_CLUSTER + ) + device:send(messages.ZigbeeMessageTx({ + address_header = address, + body = zcl_messages.ZclMessageBody({ + zcl_header = header, + zcl_body = generic_body.GenericBody(payload), + }), + })) +end + +local function send_private_write(device, attr_id, data_type, value) + local record = global_commands.WriteAttribute.AttributeRecord( + data_types.AttributeId(attr_id), + data_types.ZigbeeDataType(data_type.ID), + data_type(value) + ) + local header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(global_commands.WriteAttribute.ID), + }) + local address = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + private_endpoint(device), + zb_const.HA_PROFILE_ID, + RTI_TEK_PRIVATE_CLUSTER + ) + device:send(messages.ZigbeeMessageTx({ + address_header = address, + body = zcl_messages.ZclMessageBody({ + zcl_header = header, + zcl_body = global_commands.WriteAttribute({ record }), + }), + })) +end + +local function log_link_metrics(device, zb_rx) + if zb_rx ~= nil and (zb_rx.lqi ~= nil or zb_rx.rssi ~= nil) then + device.log.debug_with({ hub_logs = true }, string.format( + "Rti-Tek STHZB Zigbee link: LQI %s / RSSI %s dBm", + tostring(zb_rx.lqi), tostring(zb_rx.rssi) + )) + end +end + +local function do_refresh(driver, device) + device:send(TemperatureMeasurement.attributes.MeasuredValue:read(device)) + device:send(RelativeHumidity.attributes.MeasuredValue:read(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) + for _, attr_id in ipairs(PRIVATE_ATTRIBUTES) do + send_private_read(device, attr_id) + end +end + +local function do_configure(driver, device) + device:send(device_management.build_bind_request( + device, TemperatureMeasurement.ID, driver.environment_info.hub_zigbee_eui)) + device:send(device_management.build_bind_request( + device, RelativeHumidity.ID, driver.environment_info.hub_zigbee_eui)) + device:send(device_management.build_bind_request( + device, PowerConfiguration.ID, driver.environment_info.hub_zigbee_eui)) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 10, 300, 50)) + device:send(RelativeHumidity.attributes.MeasuredValue:configure_reporting(device, 10, 300, 100)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 30, 21600, 5)) + do_refresh(driver, device) +end + +local function init(driver, device) + for name, value in pairs(DEFAULT_RAW_VALUES) do + local setting = PREFERENCE_FIELDS[name] + if device:get_field(setting.field) == nil then + device:set_field(setting.field, value, { persist = true }) + end + end +end + +local function temperature_handler(driver, device, value, zb_rx) + if value.value ~= -32768 then + device:emit_event(capabilities.temperatureMeasurement.temperature({ + value = value.value / 100, + unit = "C", + })) + end + log_link_metrics(device, zb_rx) +end + +local function humidity_handler(driver, device, value, zb_rx) + if value.value ~= 0xFFFF then + device:emit_event(capabilities.relativeHumidityMeasurement.humidity({ + value = value.value / 100, + })) + end + log_link_metrics(device, zb_rx) +end + +local function battery_handler(driver, device, value, zb_rx) + local percentage = math.floor(value.value / 2 + 0.5) + if percentage >= 0 and percentage <= 100 then + device:emit_event(capabilities.battery.battery(percentage)) + end + log_link_metrics(device, zb_rx) +end + +local function cache_private_value(device, field, value, zb_rx) + device:set_field(field, value.value, { persist = true }) + log_link_metrics(device, zb_rx) +end + +local function temperature_unit_handler(driver, device, value, zb_rx) + device:set_field("rtitek_temperature_unit", value.value, { persist = true }) + log_link_metrics(device, zb_rx) +end + +local function fault_code_handler(driver, device, value, zb_rx) + device:set_field("rtitek_fault_code", value.value, { persist = true }) + device.log.info_with({ hub_logs = true }, string.format( + "Rti-Tek STHZB fault code: 0x%X", value.value + )) + log_link_metrics(device, zb_rx) +end + +local function temperature_alarm_status_handler(driver, device, value, zb_rx) + local raw = value.value + device:set_field("rtitek_temperature_alarm_status", raw, { persist = true }) + if raw == 1 then + device:emit_event(capabilities.temperatureAlarm.temperatureAlarm.freeze()) + elseif raw == 2 then + device:emit_event(capabilities.temperatureAlarm.temperatureAlarm.heat()) + else + device:emit_event(capabilities.temperatureAlarm.temperatureAlarm.cleared()) + end + log_link_metrics(device, zb_rx) +end + +local function humidity_alarm_status_handler(driver, device, value, zb_rx) + device:set_field("rtitek_humidity_alarm_status", value.value, { persist = true }) + device.log.info_with({ hub_logs = true }, string.format( + "Rti-Tek STHZB humidity alarm status: %s", tostring(value.value) + )) + log_link_metrics(device, zb_rx) +end + +local function write_preference(device, name, value) + local setting = PREFERENCE_FIELDS[name] + if setting == nil or type(value) ~= "number" then + return + end + + local raw = raw_value(value, setting) + raw = clamp_paired_limit( + raw, + setting.paired_field and device:get_field(setting.paired_field), + setting.is_upper, + setting.gap + ) + if raw ~= raw_value(value, setting) then + device.log.warn_with({ hub_logs = true }, string.format( + "Rti-Tek STHZB clamped %s from %s to %.1f", + name, tostring(value), raw / setting.scale + )) + end + device:set_field(setting.field, raw, { persist = true }) + send_private_write(device, setting.attr, setting.data_type, raw) + send_private_read(device, setting.attr) +end + +local function info_changed(driver, device, event, args) + local old_preferences = args.old_st_store and args.old_st_store.preferences or {} + local new_preferences = device.preferences or {} + + if old_preferences.temperatureUnit ~= new_preferences.temperatureUnit then + local unit = tonumber(new_preferences.temperatureUnit) + if unit == 0 or unit == 1 then + device:set_field("rtitek_temperature_unit", unit, { persist = true }) + send_private_write(device, TEMPERATURE_UNIT_ATTR, data_types.Enum8, unit) + send_private_read(device, TEMPERATURE_UNIT_ATTR) + end + end + + for name, _ in pairs(PREFERENCE_FIELDS) do + if old_preferences[name] ~= new_preferences[name] then + write_preference(device, name, new_preferences[name]) + end + end +end + +local rtitek_sthzb = { + NAME = "Rti-Tek STHZB", + can_handle = require "rtitek-sthzb.can_handle", + lifecycle_handlers = { + init = init, + doConfigure = do_configure, + infoChanged = info_changed, + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + }, + }, + zigbee_handlers = { + attr = { + [TemperatureMeasurement.ID] = { + [TemperatureMeasurement.attributes.MeasuredValue.ID] = temperature_handler, + }, + [RelativeHumidity.ID] = { + [RelativeHumidity.attributes.MeasuredValue.ID] = humidity_handler, + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_handler, + }, + [RTI_TEK_PRIVATE_CLUSTER] = { + [TEMPERATURE_UNIT_ATTR] = temperature_unit_handler, + [FAULT_CODE_ATTR] = fault_code_handler, + [TEMPERATURE_CALIBRATION_ATTR] = function(d, dev, v, rx) + cache_private_value(dev, "rtitek_temperature_calibration", v, rx) + end, + [HUMIDITY_CALIBRATION_ATTR] = function(d, dev, v, rx) + cache_private_value(dev, "rtitek_humidity_calibration", v, rx) + end, + [TEMPERATURE_ALARM_UPPER_ATTR] = function(d, dev, v, rx) + cache_private_value(dev, "rtitek_temperature_alarm_upper", v, rx) + end, + [TEMPERATURE_ALARM_LOWER_ATTR] = function(d, dev, v, rx) + cache_private_value(dev, "rtitek_temperature_alarm_lower", v, rx) + end, + [HUMIDITY_ALARM_UPPER_ATTR] = function(d, dev, v, rx) + cache_private_value(dev, "rtitek_humidity_alarm_upper", v, rx) + end, + [HUMIDITY_ALARM_LOWER_ATTR] = function(d, dev, v, rx) + cache_private_value(dev, "rtitek_humidity_alarm_lower", v, rx) + end, + [TEMPERATURE_ALARM_STATUS_ATTR] = temperature_alarm_status_handler, + [HUMIDITY_ALARM_STATUS_ATTR] = humidity_alarm_status_handler, + }, + }, + }, +} + +return rtitek_sthzb diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua index 6f49a5f18c..be4848b7ab 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/sub_drivers.lua @@ -9,5 +9,6 @@ local sub_drivers = { lazy_load_if_possible("centralite-sensor"), lazy_load_if_possible("heiman-sensor"), lazy_load_if_possible("frient-sensor"), + lazy_load_if_possible("rtitek-sthzb"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua index 96b4f2cd31..9149b1f090 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua @@ -4,21 +4,50 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local generic_body = require "st.zigbee.generic_body" +local global_commands = require "st.zigbee.zcl.global_commands" +local messages = require "st.zigbee.messages" +local report_attr = require "st.zigbee.zcl.global_commands.report_attribute" local t_utils = require "integration_test.utils" +local write_attr = require "st.zigbee.zcl.global_commands.write_attribute" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local PowerConfiguration = clusters.PowerConfiguration local RelativeHumidity = clusters.RelativeHumidity local TemperatureMeasurement = clusters.TemperatureMeasurement +local FD22 = 0xFD22 +local UNIT = 0x0000 +local FAULT = 0x0002 +local TEMP_CAL = 0xE005 +local HUMIDITY_CAL = 0xE006 +local TEMP_HIGH = 0xE00A +local TEMP_LOW = 0xE00B +local HUMIDITY_HIGH = 0xE00C +local HUMIDITY_LOW = 0xE00D +local TEMP_ALARM = 0xE00E +local HUMIDITY_ALARM = 0xE00F + local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("rtitek-sthzb.yml"), + preferences = { + temperatureUnit = "0", + temperatureCalibration = 0, + humidityCalibration = 0, + temperatureAlarmUpper = 26, + temperatureAlarmLower = 20, + humidityAlarmUpper = 60, + humidityAlarmLower = 30, + }, zigbee_endpoints = { [1] = { id = 1, manufacturer = "Rti-Tek", model = "STHZB", - server_clusters = { 0x0001, 0x0402, 0x0405, 0xFD22 }, + server_clusters = { 0x0001, 0x0402, 0x0405, FD22 }, }, }, }) @@ -31,6 +60,64 @@ end test.set_test_init_function(test_init) +local function fd22_address(source) + if source then + return messages.AddressHeader( + mock_device:get_short_address(), 0x01, zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, zb_const.HA_PROFILE_ID, FD22 + ) + end + return messages.AddressHeader( + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, mock_device:get_short_address(), + 0x01, zb_const.HA_PROFILE_ID, FD22 + ) +end + +local function build_fd22_read(attr_id) + local payload = string.char(attr_id & 0xFF, (attr_id >> 8) & 0xFF) + return messages.ZigbeeMessageTx({ + address_header = fd22_address(false), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(global_commands.ReadAttribute.ID), + }), + zcl_body = generic_body.GenericBody(payload), + }), + }) +end + +local function build_fd22_write(attr_id, data_type, value) + local record = write_attr.WriteAttribute.AttributeRecord( + data_types.AttributeId(attr_id), + data_types.ZigbeeDataType(data_type.ID), + data_type(value) + ) + return messages.ZigbeeMessageTx({ + address_header = fd22_address(false), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(write_attr.WriteAttribute.ID), + }), + zcl_body = write_attr.WriteAttribute({ record }), + }), + }) +end + +local function build_fd22_report(attr_id, data_type, value) + local report = report_attr.ReportAttribute({ + report_attr.ReportAttributeAttributeRecord(attr_id, data_type.ID, value), + }) + return messages.ZigbeeMessageRx({ + address_header = fd22_address(true), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(report.ID), + }), + zcl_body = report, + }), + }) +end + test.register_message_test( "STHZB standard temperature report is exposed in Celsius", { @@ -96,27 +183,119 @@ test.register_message_test( ) test.register_coroutine_test( - "STHZB refresh reads standard measurement attributes", + "STHZB refresh reads standard and private attributes", function() test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }, }) + test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.zigbee:__expect_send({ - mock_device.id, - TemperatureMeasurement.attributes.MeasuredValue:read(mock_device), + mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device), }) test.socket.zigbee:__expect_send({ - mock_device.id, - RelativeHumidity.attributes.MeasuredValue:read(mock_device), + mock_device.id, RelativeHumidity.attributes.MeasuredValue:read(mock_device), }) test.socket.zigbee:__expect_send({ - mock_device.id, - PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device), + mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device), + }) + for _, attr_id in ipairs({ + UNIT, FAULT, TEMP_CAL, HUMIDITY_CAL, TEMP_HIGH, + TEMP_LOW, HUMIDITY_HIGH, HUMIDITY_LOW, TEMP_ALARM, HUMIDITY_ALARM, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, build_fd22_read(attr_id) }) + end + test.wait_for_events() + end, + { min_api_version = 14 } +) + +local function register_temperature_alarm_test(name, raw, event) + test.register_message_test( + name, + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, build_fd22_report(TEMP_ALARM, data_types.Enum8, raw) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", event), + }, + }, + { min_api_version = 14 } + ) +end + +register_temperature_alarm_test( + "STHZB maps normal temperature alarm to cleared", + 0, + capabilities.temperatureAlarm.temperatureAlarm.cleared() +) +register_temperature_alarm_test( + "STHZB maps low temperature alarm to freeze", + 1, + capabilities.temperatureAlarm.temperatureAlarm.freeze() +) +register_temperature_alarm_test( + "STHZB maps high temperature alarm to heat", + 2, + capabilities.temperatureAlarm.temperatureAlarm.heat() +) + +test.register_coroutine_test( + "STHZB writes calibration and temperature unit preferences with device precision", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + temperatureUnit = "1", + temperatureCalibration = -4.1, + humidityCalibration = 3.2, + }, + })) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, build_fd22_write(UNIT, data_types.Enum8, 1), + }) + test.socket.zigbee:__expect_send({ mock_device.id, build_fd22_read(UNIT) }) + test.socket.zigbee:__expect_send({ + mock_device.id, build_fd22_write(TEMP_CAL, data_types.Int8, -41), }) + test.socket.zigbee:__expect_send({ mock_device.id, build_fd22_read(TEMP_CAL) }) + test.socket.zigbee:__expect_send({ + mock_device.id, build_fd22_write(HUMIDITY_CAL, data_types.Int8, 32), + }) + test.socket.zigbee:__expect_send({ mock_device.id, build_fd22_read(HUMIDITY_CAL) }) test.wait_for_events() end, - { min_api_version = 14, inner_block_ordering = "relaxed" } + { min_api_version = 14 } +) + +test.register_coroutine_test( + "STHZB normalizes humidity alarm preference and clamps paired limits", + function() + mock_device:set_field("rtitek_temperature_alarm_lower", 2000, { persist = true }) + mock_device:set_field("rtitek_humidity_alarm_lower", 3000, { persist = true }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + temperatureAlarmUpper = 20, + humidityAlarmUpper = 21.22, + }, + })) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, build_fd22_write(TEMP_HIGH, data_types.Int16, 2010), + }) + test.socket.zigbee:__expect_send({ mock_device.id, build_fd22_read(TEMP_HIGH) }) + test.socket.zigbee:__expect_send({ + mock_device.id, build_fd22_write(HUMIDITY_HIGH, data_types.Uint16, 3100), + }) + test.socket.zigbee:__expect_send({ mock_device.id, build_fd22_read(HUMIDITY_HIGH) }) + test.wait_for_events() + end, + { min_api_version = 14 } ) test.run_registered_tests() From 2e0ac7315b3f6c5af9d7d1c5e850bcbef579ffcb Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Fri, 28 Aug 2026 14:56:57 +0800 Subject: [PATCH 4/4] fix(zigbee): validate Rti-Tek alarm preferences consistently --- .../src/rtitek-sthzb/init.lua | 34 ++++++++++++++----- .../src/test/test_rtitek_sthzb.lua | 9 +++-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua index f8c7971bff..702e25197e 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/rtitek-sthzb/init.lua @@ -74,6 +74,7 @@ local PREFERENCE_FIELDS = { data_type = data_types.Int16, field = "rtitek_temperature_alarm_upper", paired_field = "rtitek_temperature_alarm_lower", + paired_preference = "temperatureAlarmLower", minimum = -30, maximum = 60, scale = 100, @@ -86,6 +87,7 @@ local PREFERENCE_FIELDS = { data_type = data_types.Int16, field = "rtitek_temperature_alarm_lower", paired_field = "rtitek_temperature_alarm_upper", + paired_preference = "temperatureAlarmUpper", minimum = -30, maximum = 60, scale = 100, @@ -98,6 +100,7 @@ local PREFERENCE_FIELDS = { data_type = data_types.Uint16, field = "rtitek_humidity_alarm_upper", paired_field = "rtitek_humidity_alarm_lower", + paired_preference = "humidityAlarmLower", minimum = 0, maximum = 100, scale = 100, @@ -110,6 +113,7 @@ local PREFERENCE_FIELDS = { data_type = data_types.Uint16, field = "rtitek_humidity_alarm_lower", paired_field = "rtitek_humidity_alarm_upper", + paired_preference = "humidityAlarmUpper", minimum = 0, maximum = 100, scale = 100, @@ -153,6 +157,21 @@ local function clamp_paired_limit(raw, paired_raw, is_upper, gap) return raw end +local function raw_preference_value(preferences, name) + local setting = PREFERENCE_FIELDS[name] + local value = preferences and preferences[name] + if setting == nil or type(value) ~= "number" then + return nil + end + return raw_value(value, setting) +end + +local function clamp_raw_to_setting(raw, setting) + local minimum = raw_value(setting.minimum, setting) + local maximum = raw_value(setting.maximum, setting) + return math.max(minimum, math.min(maximum, raw)) +end + local function send_private_read(device, attr_id) local payload = string.char(attr_id & 0xFF, (attr_id >> 8) & 0xFF) local header = zcl_messages.ZclHeader({ @@ -307,19 +326,18 @@ local function humidity_alarm_status_handler(driver, device, value, zb_rx) log_link_metrics(device, zb_rx) end -local function write_preference(device, name, value) +local function write_preference(device, name, value, preferences) local setting = PREFERENCE_FIELDS[name] if setting == nil or type(value) ~= "number" then return end local raw = raw_value(value, setting) - raw = clamp_paired_limit( - raw, - setting.paired_field and device:get_field(setting.paired_field), - setting.is_upper, - setting.gap - ) + local paired_raw = raw_preference_value(preferences, setting.paired_preference) + if paired_raw == nil and setting.paired_field ~= nil then + paired_raw = device:get_field(setting.paired_field) + end + raw = clamp_raw_to_setting(clamp_paired_limit(raw, paired_raw, setting.is_upper, setting.gap), setting) if raw ~= raw_value(value, setting) then device.log.warn_with({ hub_logs = true }, string.format( "Rti-Tek STHZB clamped %s from %s to %.1f", @@ -346,7 +364,7 @@ local function info_changed(driver, device, event, args) for name, _ in pairs(PREFERENCE_FIELDS) do if old_preferences[name] ~= new_preferences[name] then - write_preference(device, name, new_preferences[name]) + write_preference(device, name, new_preferences[name], new_preferences) end end end diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua index 9149b1f090..65d5c4ee7d 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_rtitek_sthzb.lua @@ -274,14 +274,17 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "STHZB normalizes humidity alarm preference and clamps paired limits", + "STHZB validates alarm limits against saved preferences instead of stale device values", function() - mock_device:set_field("rtitek_temperature_alarm_lower", 2000, { persist = true }) + -- The device has a stale lower limit of 17.2 C, while the App setting is 20 C. + mock_device:set_field("rtitek_temperature_alarm_lower", 1720, { persist = true }) mock_device:set_field("rtitek_humidity_alarm_lower", 3000, { persist = true }) test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { - temperatureAlarmUpper = 20, + temperatureAlarmUpper = 19, + temperatureAlarmLower = 20, humidityAlarmUpper = 21.22, + humidityAlarmLower = 30, }, })) test.socket.zigbee:__set_channel_ordering("relaxed")