diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index a889663b92..b126382f20 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -1029,6 +1029,11 @@ matterManufacturer: vendorId: 0x1285 productId: 0x000A deviceProfileName: matter-bridge + - id: "4741/12" + deviceLabel: Hager Rotary Dimmer + vendorId: 0x1285 + productId: 0x000C + deviceProfileName: light-level # HAOJAI - id: "5530/4113" deviceLabel: HAOJAI Smart Switch 3-key @@ -4283,6 +4288,11 @@ matterManufacturer: vendorId: 0x100B productId: 0x0002 deviceProfileName: matter-bridge + - id: "4107/3" + deviceLabel: Hue Bridge Pro + vendorId: 0x100B + productId: 0x0003 + deviceProfileName: matter-bridge - id: "Switchbot/hub2" deviceLabel: Matter Bridge vendorId: 0x1397 @@ -4346,6 +4356,12 @@ matterManufacturer: vendorId: 0x1664 productId: 0x0300 deviceProfileName: light-color-level +#EGLO Connect.z + - id: "4751/32929" + deviceLabel: YEBRA-Z LED + vendorId: 0x128F + productId: 0x80A1 + deviceProfileName: light-color-level matterGeneric: diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua index 8df254bde8..959b6fda10 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -23,6 +23,7 @@ local PRODUCT_ID = { SWITCH_2G = 0x0006, PIR_1_1M = 0x0007, PIR_2_2M = 0x000A, + ROTARY_DIMMER = 0x000C } if version.api < 16 then @@ -33,6 +34,21 @@ local function subscribe (device, endpoint_id, cluster_id, attr_id, event_id) device:send(cluster_base.subscribe(device, endpoint_id, cluster_id, attr_id, event_id)) end +-- The Rotary Dimmer (0x000C) is a non-bridge type Hager Matter device, which +-- additionally supports manual mode transitions that enable/disable +-- functionality via a physical knob. Unlike bridge-type Hager devices, it has +-- no Aggregator device type on its root endpoint. This non-bridge device +-- requires explicit handling to: (1) process endpoint EP1 (normally filtered +-- out for bridge devices), (2) manage its own device type changes without a +-- parent-child hierarchy, endpoint mapping and event routing, (3) support +-- dynamic profile switching triggered by user interaction, as with the knob +-- mode switching from Dimmer to only On/Off. +local function is_non_bridge_type(device) + if switch_utils.get_product_override_field(device, "is_non_bridge_type") then + return true + end +end + local function get_parent (driver, device) return driver:get_device_info(device:get_field(PARENT_ID) or nil) end @@ -51,12 +67,14 @@ local function get_matter_device(device) return nil end -local function extract_reported_endpoints(ib) +local function extract_reported_endpoints(ib, device) local eps = {} if ib.data and ib.data.elements then for _, el in ipairs(ib.data.elements) do local ep_id = el.value - if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 then + -- For bridge architecture HBnet devices: filter out endpoints 1-2 (reserved for hub infrastructure) + -- For simple non-bridge type HBnet devices: accept all endpoints including 1-2 (active application endpoints) + if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 or is_non_bridge_type(device) then table.insert(eps, ep_id) end end @@ -188,6 +206,11 @@ local function info_changed(driver, device, event, args) return end + if is_non_bridge_type(device) then + device:subscribe() + return + end + if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then device.thread:call_with_delay(3, function() local parent = device:get_parent_device() @@ -225,7 +248,7 @@ local function info_changed(driver, device, event, args) subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID) subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) end - if device.id == matter_device.id then + if not is_non_bridge_type(device) and device.id == matter_device.id then parent:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) matter_device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) end @@ -234,6 +257,13 @@ local function info_changed(driver, device, event, args) end local function device_init (driver, device) + if is_non_bridge_type(device) then + device:set_find_child(switch_utils.find_child) + device:set_field(PARENT_ID, device.id) + device:set_field(MATTER_DEVICE_ID, device.id) + subscribe(device, nil, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID, nil) + return + end if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then device.thread:call_with_delay(4, function() info_changed(driver, device, nil, { @@ -265,7 +295,6 @@ local function device_init (driver, device) end) end) end - device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) end @@ -274,21 +303,26 @@ local function handle_descriptor_report(driver, device, ib, response) local product_id = device.manufacturer_info and device.manufacturer_info.product_id local parent = get_parent(driver, device) - if not parent or ib.endpoint_id ~= 2 then - return + if is_non_bridge_type(device) then + if ib.endpoint_id ~= 0 then + return + end + else + if ib.endpoint_id ~= 2 or not parent then + return + end end - local matter_device = get_matter_device(device) + local matter_device = get_matter_device(device) or device + local new_eps = extract_reported_endpoints(ib, device) or {} - local new_eps = extract_reported_endpoints(ib) or {} table.sort(new_eps) - local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) + local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) device:set_field(ACTIVE_EPS, new_eps, { persist = true }) for _, ep_id in ipairs(added_eps or {}) do subscribe(parent, ep_id, clusters.Descriptor.ID, clusters.Descriptor.attributes.DeviceTypeList.ID, nil) - if product_id == PRODUCT_ID.SWITCH_1G and ep_id == 3 then matter_device:try_update_metadata({ profile = "light-binary" }) elseif product_id == PRODUCT_ID.SWITCH_2G then @@ -347,6 +381,7 @@ local function device_type_handler (driver, device, ib) end local parent = get_parent(driver, device) + if not parent then return end @@ -356,10 +391,19 @@ local function device_type_handler (driver, device, ib) local new_btn_eps = {} local ep_id = ib.endpoint_id local value = ib.data.elements + local reports_dimmable = false + + for _, element in ipairs(value or {}) do + local device_type_field = element.elements.device_type + if device_type_field and device_type_field.value == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then + reports_dimmable = true + end + end for _, v in ipairs(stored_btn_eps) do table.insert(new_btn_eps, v) end + for _, element in ipairs(value) do local device_type_field = element.elements.device_type local device_type_id = device_type_field and device_type_field.value @@ -396,11 +440,17 @@ local function device_type_handler (driver, device, ib) driver:try_delete_device(ep_id) end return + elseif ep_id == 1 and device.manufacturer_info.product_id == PRODUCT_ID.ROTARY_DIMMER then + if not reports_dimmable then + device:try_update_metadata({ profile = "light-binary" }) + end end create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) elseif device_type_id == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then if ep_id == 4 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then return + elseif ep_id == 1 and device.manufacturer_info.product_id == PRODUCT_ID.ROTARY_DIMMER then + device:try_update_metadata({ profile = "light-level" }) end create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) elseif device_type_id == fields.DEVICE_TYPE_ID.WINDOW_COVERING then @@ -433,7 +483,7 @@ local function do_configure (driver, device) device:try_update_metadata({ profile = "4-button" }) elseif #bt_eps == 2 then device:try_update_metadata({ profile = "2-button" }) - elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G then + elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G or is_non_bridge_type(device) then device:try_update_metadata({ profile = "light-level" }) end device:set_field(BUTTON_EPS, bt_eps, { persist = true }) diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index d16214d2fd..ef52e7d65b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -125,6 +125,7 @@ SwitchFields.vendor_overrides = { [0x0006] = { needs_hager_subdriver = true }, -- Hager HBnet 2g switch [0x0007] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 1.1M [0x000A] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 2.2M + [0x000C] = { needs_hager_subdriver = true, is_non_bridge_type = true }, -- Hager Rotary Dimmer }, [0x130A] = { -- EVE_MANUFACTURER_ID [0x0050] = { needs_eve_energy_subdriver = true }, -- Eve Energy EU diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua index 666550dfcf..632cfd9fce 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -18,6 +18,7 @@ local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" local PARENT_ID = "PARENT_ID" local BUTTON_EPS = "__button_eps" local MAIN_WC_EP = "__main_wc_ep" +local ACTIVE_EPS = "__active_EPS" local function create_parent_device(product_id) return test.mock_device.build_test_matter_device({ @@ -509,6 +510,80 @@ local function create_hager_1g_relay(profile_name, parent) }) end +local function create_hager_rotary_dimmer(profile_name) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Rotary Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x000C, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + } + }) +end + +local function create_hager_rotary_switch(profile_name) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Rotary Switch", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x000C, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + local function add_parent_device(parent) test.mock_device.add_test_device(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) @@ -591,7 +666,7 @@ local function configure_parent(device) end -- Create window covering child device on EP12 via PartsList and DeviceTypeList reports -local function announce_window_covering_child(host, matter_device) +local function announce_window_covering_child(host) test.socket.matter:__queue_receive({ host.id, clusters.Descriptor.attributes.PartsList:build_test_report_data(host, 2, data_types.Array({ @@ -634,6 +709,19 @@ local function announce_window_covering_child(host, matter_device) return child_wc end +local function add_standalone_device(device, expected_profile_change) + test.mock_device.add_test_device(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + device:expect_metadata_update({ profile = expected_profile_change }) + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + test.socket.matter:__expect_send({ + device.id, + cluster_base.subscribe(device, nil, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + local parent = create_parent_device(0x0006) -- 2G button product local parent_1g = create_parent_device(0x0005) -- 1G button product local parent_pir = create_parent_device(0x0007) -- PIR product @@ -1683,7 +1771,7 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile }) test.mock_device.add_test_device(child_wc) - initiate_info_changed(child_wc, "window-covering",parent) + initiate_info_changed(child_wc, "window-covering", parent) test.socket.matter:__expect_send({ parent.id, @@ -2115,5 +2203,87 @@ end, { min_api_version = 15 }) +test.register_coroutine_test("Test: Rotary Dimmer - Standalone Initialization Subscribes To PartsList And Sets light-level", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_dimmer("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + local button_eps = rotary:get_field(BUTTON_EPS) + assert(#button_eps == 0, "BUTTON_EPS is empty for a rotary dimmer") +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Rotary Dimmer - EP1 Dimmable Device Type Sets light-level And Creates Child", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_dimmer("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(rotary, 0, data_types.Array({ + data_types.Uint16(1), + })) + }) + test.socket.matter:__expect_send({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(rotary, 1) + }) + test.wait_for_events() + + local active_eps = rotary:get_field(ACTIVE_EPS) + assert(active_eps[1] == 1, "ACTIVE_EPS contains endpoint 1") + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(rotary, 1, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + rotary:expect_metadata_update({ profile = "light-level" }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Rotary Dimmer - EP1 OnOff Device Type Sets light-binary And Creates Child", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_switch("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(rotary, 0, data_types.Array({ + data_types.Uint16(1), + })) + }) + test.socket.matter:__expect_send({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(rotary, 1) + }) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(rotary, 1, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + rotary:expect_metadata_update({ profile = "light-binary" }) +end, + { + min_api_version = 15 + }) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/fingerprints.yml b/drivers/SmartThings/matter-thermostat/fingerprints.yml index 399f3861fc..59eefb42b0 100644 --- a/drivers/SmartThings/matter-thermostat/fingerprints.yml +++ b/drivers/SmartThings/matter-thermostat/fingerprints.yml @@ -122,6 +122,12 @@ matterManufacturer: vendorId: 0x1575 productId: 0x0001 deviceProfileName: thermostat-modular + #ZEMISMART + - id: "5020/51713" + deviceLabel: Zemismart Matter WiFi IR Thermostat + vendorId: 0x139C + productId: 0xCA01 + deviceProfileName: thermostat-modular matterGeneric: - id: "matter/hvac/heatcool" deviceLabel: Matter Thermostat diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index ee36659670..086c79346a 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -93,6 +93,11 @@ zigbeeManufacturer: manufacturer: SONOFF model: SNZB-02B deviceProfileName: humidity-temp-battery + - id: SONOFF/SNZB-02UL + deviceLabel: SONOFF Zigbee Temperature Humidity Sensor + manufacturer: SONOFF + model: SNZB-02UL + deviceProfileName: humidity-temp-battery - id: "Third Reality/3RTHS24BZ" deviceLabel: ThirdReality Temperature and Humidity Sensor manufacturer: Third Reality, Inc diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index efa89be552..9f8bf3ce3c 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -1,4 +1,9 @@ zigbeeManufacturer: + - id: "SONOFF/TRV-ZBT" + deviceLabel: SONOFF TRV-ZBT + manufacturer: SONOFF + model: TRV-ZBT + deviceProfileName: thermostat-sonoff-trv-zbt - id: "Vimar/02973-smart-wheel-thermostat" deviceLabel: Vimar Smart Thermostat manufacturer: Vimar @@ -139,6 +144,11 @@ zigbeeManufacturer: manufacturer: WALL HERO model: ACL-403STC1 deviceProfileName: thermostat-thirty-buttons-wallhero + - id: "SONOFF/TP-WGZBA" + deviceLabel: SONOFF Thermostat + manufacturer: SONOFF + model: TP-WGZBA + deviceProfileName: thermostat-sonoff zigbeeGeneric: - id: "genericThermostat" deviceLabel: Zigbee Thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml new file mode 100644 index 0000000000..16ad1353b6 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml @@ -0,0 +1,38 @@ +name: thermostat-sonoff-trv-zbt +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + - id: thermostatMode + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: +- name: "childLock" + title: "Child lock" + description: "Enable or disable the child lock of the thermostat." + required: false + preferenceType: enumeration + definition: + options: + 0: "Unlock" + 1: "Lock" + default: 0 +- name: "enterBluetoothPairing" + title: "Enter Bluetooth pairing" + description: "Enable to put the thermostat into Bluetooth pairing mode. The pairing window is controlled by the thermostat firmware." + required: false + preferenceType: boolean + definition: + default: false diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml new file mode 100644 index 0000000000..80bd8a5b1d --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml @@ -0,0 +1,248 @@ +name: thermostat-sonoff +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - heating + - idle + - id: thermostatMode + version: 1 + config: + values: + - key: "thermostatMode.value" + enabledValues: + - off + - auto + - heat + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + step: 0.5 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: +- name: temperatureCompensation + title: Temperature compensation + description: Local temperature calibration in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: -10 + maximum: 10 + default: 0 +- name: childLock + title: Child lock + description: Lock the thermostat controls. + required: true + preferenceType: boolean + definition: + default: false +- name: btPairingBroadcast + title: Bluetooth pairing broadcast + description: Enable Bluetooth pairing broadcasts. + required: true + preferenceType: boolean + definition: + default: false +- name: openWindowDetection + title: Open window detection + description: Enable open-window detection. + required: true + preferenceType: boolean + definition: + default: false +- name: frostProofTemperature + title: Frost protection temperature + description: Frost protection setpoint in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 5 + maximum: 15 + default: 5 +- name: temporaryModeAction + title: Temporary mode + description: Apply an override using the duration and temperature below. + required: true + preferenceType: enumeration + definition: + options: + exit: Exit + boost: Boost + timer: Timer + default: exit +- name: temporaryModeDuration + title: Temporary mode duration + description: Override duration in minutes. + required: true + preferenceType: integer + definition: + minimum: 1 + maximum: 1440 + default: 30 +- name: temporaryModeTemperature + title: Temporary mode temperature + description: Timer override temperature in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 5 + maximum: 30 + default: 30 +- name: temperatureThresholdLow + title: Temperature threshold low + description: Lower temperature-control threshold in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: -2.6 + maximum: -0.2 + default: -2 +- name: temperatureThresholdHigh + title: Temperature threshold high + description: Upper temperature-control threshold in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 2.6 + default: 2 +- name: radarSensitivity + title: Radar sensitivity + description: Set the radar sensitivity level. + required: true + preferenceType: enumeration + definition: + options: + "0": Low + "1": Medium + "2": High + default: "2" +- name: radarDoNotDisturb + title: Radar do not disturb + description: Enable radar do-not-disturb mode. + required: true + preferenceType: boolean + definition: + default: false +- name: radarDoNotDisturbStart + title: Radar do not disturb start + description: Start time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: radarDoNotDisturbEnd + title: Radar do not disturb end + description: End time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: screenWorkingBrightness + title: Screen working brightness + description: Screen brightness while active. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 5 +- name: screenStandbyBrightness + title: Screen standby brightness + description: Screen brightness while on standby. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 0 +- name: nightStandbyBrightness + title: Screen night standby brightness + description: Screen brightness during night standby. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 0 +- name: screenNightMode + title: Screen night mode + description: Enable screen night mode. + required: true + preferenceType: boolean + definition: + default: false +- name: screenNightStart + title: Screen night mode start + description: Start time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: screenNightEnd + title: Screen night mode end + description: End time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: heatRelayNormClosed + title: Heating relay normally closed + description: Configure the heating relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: boilerRelayNormClosed + title: Boiler relay normally closed + description: Configure the boiler relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: overheatProtectTemp + title: Overheat protection temperature + description: Overheat protection setpoint in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 20 + maximum: 50 + default: 33.5 +- name: overheatProtection + title: Overheat protection + description: Enable overheat protection. + required: true + preferenceType: boolean + definition: + default: false +- name: radarEnabled + title: Radar enabled + description: Enable the radar sensor. + required: true + preferenceType: boolean + definition: + default: false diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua new file mode 100644 index 0000000000..9b270451e8 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device) + if device:get_manufacturer() == "SONOFF" and device:get_model() == "TRV-ZBT" then + return true, require("sonoff") + end + + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua new file mode 100644 index 0000000000..0097dae163 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -0,0 +1,321 @@ +-- 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 cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local generic_body = require "st.zigbee.generic_body" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zb_const = require "st.zigbee.constants" +local utils = require "st.utils" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat + +local Battery = capabilities.battery +local Refresh = capabilities.refresh +local TemperatureMeasurement = capabilities.temperatureMeasurement +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +local WORK_MODE_ATTR = 0x0018 +local WORK_MODE_OFF = 0x01 +local WORK_MODE_MANUAL = 0x03 +local WORK_MODE_SCHEDULE = 0x04 +local WORK_MODE_TEMP_MANUAL = 0x05 + +local DEFAULT_MIN_SETPOINT = 5 +local DEFAULT_MAX_SETPOINT = 30 +local SETPOINT_STEP = 0.5 +local CHILD_LOCK_PREFERENCE = "childLock" +local BLUETOOTH_PAIRING_PREFERENCE = "enterBluetoothPairing" +local MIN_HEAT_SETPOINT_LIMIT_FIELD = "min_heat_setpoint_limit" +local MAX_HEAT_SETPOINT_LIMIT_FIELD = "max_heat_setpoint_limit" +local SUPPORTED_MODES = { + ThermostatMode.thermostatMode.off.NAME, + ThermostatMode.thermostatMode.heat.NAME, + ThermostatMode.thermostatMode.auto.NAME, +} + +local CONFIGURATIONS = { + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.LocalTemperature.ID, + minimum_interval = 10, + maximum_interval = 300, + data_type = Thermostat.attributes.LocalTemperature.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.OccupiedHeatingSetpoint.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.OccupiedHeatingSetpoint.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.SystemMode.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.SystemMode.base_type, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MinHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MinHeatSetpointLimit.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MaxHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MaxHeatSetpointLimit.base_type, + reportable_change = 50, + }, +} + +local function get_setpoint_range(device) + local reported_min = device:get_field(MIN_HEAT_SETPOINT_LIMIT_FIELD) + local reported_max = device:get_field(MAX_HEAT_SETPOINT_LIMIT_FIELD) + local min = reported_min and (reported_min / 100) or DEFAULT_MIN_SETPOINT + local max = reported_max and (reported_max / 100) or DEFAULT_MAX_SETPOINT + + if min > max then + min = DEFAULT_MIN_SETPOINT + max = DEFAULT_MAX_SETPOINT + end + + return min, max +end + +local function emit_setpoint_range(device) + local min, max = get_setpoint_range(device) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ + value = { + minimum = min, + maximum = max, + step = SETPOINT_STEP, + }, + unit = "C", + }, { visibility = { displayed = false } })) +end + +local function emit_supported_modes(device) + device:emit_event(ThermostatMode.supportedThermostatModes(SUPPORTED_MODES, { + visibility = { displayed = false }, + })) +end + +local function emit_mode(device, mode) + device:emit_event(ThermostatMode.thermostatMode[mode]()) +end + +local function command_argument(command, name) + if command.args ~= nil and command.args[name] ~= nil then + return command.args[name] + end + + return command.named_args and command.named_args[name] +end + +local function refresh(_, device) + local attributes = { + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + } + + for _, attribute in ipairs(attributes) do + device:send(attribute:read(device)) + end +end + +local function configure(driver, device) + device:configure() + refresh(driver, device) + device:send(Thermostat.attributes.MinHeatSetpointLimit:read(device)) + device:send(Thermostat.attributes.MaxHeatSetpointLimit:read(device)) +end + +local function added(_, device) + emit_supported_modes(device) + emit_setpoint_range(device) +end + +local function init(driver, device) + for _, configuration in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(configuration) + end + added(driver, device) + refresh(driver, device) +end + +local function info_changed(_, device, _, args) + local old_preferences = args.old_st_store and args.old_st_store.preferences or {} + local child_lock = device.preferences[CHILD_LOCK_PREFERENCE] + if child_lock ~= nil and old_preferences[CHILD_LOCK_PREFERENCE] ~= child_lock then + device:send(cluster_base.write_attribute( + device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(tonumber(child_lock) == 1, data_types.Boolean, "payload") + )) + end + + local bluetooth_pairing = device.preferences[BLUETOOTH_PAIRING_PREFERENCE] + if bluetooth_pairing == true and old_preferences[BLUETOOTH_PAIRING_PREFERENCE] ~= true then + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(0x10), + }) + zcl_header.frame_ctrl:set_cluster_specific() + device:send(messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(SONOFF_CLUSTER) or 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + })) + end +end + +local function local_temperature_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(TemperatureMeasurement.temperature({ value = value.value / 100, unit = "C" })) + end +end + +local function heating_setpoint_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = value.value / 100, unit = "C" })) + end +end + +local function setpoint_limit_handler(field) + return function(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:set_field(field, value.value, { persist = true }) + emit_setpoint_range(device) + end + end +end + +local function system_mode_handler(_, device, value) + if value.value == Thermostat.attributes.SystemMode.OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == Thermostat.attributes.SystemMode.AUTO then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == Thermostat.attributes.SystemMode.HEAT then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function battery_handler(_, device, value) + device:emit_event(Battery.battery(utils.clamp_value(utils.round(value.value / 2), 0, 100))) +end + +local function work_mode_handler(_, device, value) + if value.value == WORK_MODE_OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == WORK_MODE_SCHEDULE then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == WORK_MODE_MANUAL or value.value == WORK_MODE_TEMP_MANUAL then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function set_heating_setpoint(_, device, command) + local setpoint = command_argument(command, "setpoint") + if setpoint >= 40 then + setpoint = utils.f_to_c(setpoint) + end + + local min, max = get_setpoint_range(device) + setpoint = utils.clamp_value(setpoint, min, max) + -- The TRV-ZBT persists heating setpoints in 0.5 C increments. + setpoint = math.floor((setpoint / SETPOINT_STEP) + 0.000001) * SETPOINT_STEP + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) +end + +local function set_mode(_, device, command) + local mode_to_system_mode = { + [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, + [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, + [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, + } + local mode = command_argument(command, "mode") + local system_mode = mode_to_system_mode[mode] + if system_mode == nil then + return + end + + device:send(Thermostat.attributes.SystemMode:write(device, system_mode)) + emit_mode(device, mode) +end + +local function mode_setter(mode) + return function(driver, device, command) + set_mode(driver, device, { args = { mode = mode } }) + end +end + +local sonoff = { + NAME = "SONOFF TRV-ZBT", + capability_handlers = { + [Refresh.ID] = { + [Refresh.commands.refresh.NAME] = refresh, + }, + [ThermostatHeatingSetpoint.ID] = { + [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, + }, + [ThermostatMode.ID] = { + [ThermostatMode.commands.setThermostatMode.NAME] = set_mode, + [ThermostatMode.commands.off.NAME] = mode_setter(ThermostatMode.thermostatMode.off.NAME), + [ThermostatMode.commands.heat.NAME] = mode_setter(ThermostatMode.thermostatMode.heat.NAME), + [ThermostatMode.commands.auto.NAME] = mode_setter(ThermostatMode.thermostatMode.auto.NAME), + }, + }, + zigbee_handlers = { + attr = { + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_handler, + }, + [Thermostat.ID] = { + [Thermostat.attributes.LocalTemperature.ID] = local_temperature_handler, + [Thermostat.attributes.OccupiedHeatingSetpoint.ID] = heating_setpoint_handler, + [Thermostat.attributes.MinHeatSetpointLimit.ID] = setpoint_limit_handler(MIN_HEAT_SETPOINT_LIMIT_FIELD), + [Thermostat.attributes.MaxHeatSetpointLimit.ID] = setpoint_limit_handler(MAX_HEAT_SETPOINT_LIMIT_FIELD), + [Thermostat.attributes.SystemMode.ID] = system_mode_handler, + }, + [SONOFF_CLUSTER] = { + [WORK_MODE_ATTR] = work_mode_handler, + }, + }, + }, + lifecycle_handlers = { + added = added, + init = init, + doConfigure = configure, + infoChanged = info_changed, + }, + can_handle = require "sonoff.can_handle", +} + +return sonoff diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua new file mode 100644 index 0000000000..0a97dad57a --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local fingerprints = require "sonoff.tp_wgzba.fingerprints" + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sonoff.tp_wgzba") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua new file mode 100644 index 0000000000..664e907947 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "SONOFF", model = "TP-WGZBA" } +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua new file mode 100644 index 0000000000..89e21ca91e --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua @@ -0,0 +1,206 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +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 utils = require "st.utils" +local ZigbeeConstants = require "st.zigbee.constants" +local ZigbeeMessages = require "st.zigbee.messages" +local ZigbeeZcl = require "st.zigbee.zcl" + +local Thermostat = clusters.Thermostat +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_MFG_CODE = 0x1286 +local SONOFF_PRIVATE_CLUSTER = 0xFC11 +local TEMPORARY_MODE_COMMAND_ID = 0x11 + +local PRIVATE_ATTR_CHILD_LOCK = 0x0000 +local PRIVATE_ATTR_BT_PAIRING_BROADCAST = 0x0029 +local PRIVATE_ATTR_OPEN_WINDOW_DETECTION = 0x6000 +local PRIVATE_ATTR_FROST_PROOF_TEMPERATURE = 0x6002 +local PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD = 0x601F +local PRIVATE_ATTR_RADAR_SENSITIVITY = 0x6020 +local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB = 0x6021 +local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD = 0x6022 +local PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS = 0x6023 +local PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS = 0x6024 +local PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS = 0x6025 +local PRIVATE_ATTR_SCREEN_NIGHT_MODE = 0x6026 +local PRIVATE_ATTR_SCREEN_NIGHT_PERIOD = 0x6027 +local PRIVATE_ATTR_RELAY_OUTPUT_TYPE = 0x6028 +local PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE = 0x6032 +local PRIVATE_ATTR_OVERHEAT_PROTECTION = 0x6034 +local PRIVATE_ATTR_RADAR_ENABLED = 0x6035 + +local PRIVATE_PREFERENCES = { + childLock = { attribute = PRIVATE_ATTR_CHILD_LOCK, data_type = data_types.Boolean }, + btPairingBroadcast = { attribute = PRIVATE_ATTR_BT_PAIRING_BROADCAST, data_type = data_types.Uint8 }, + openWindowDetection = { attribute = PRIVATE_ATTR_OPEN_WINDOW_DETECTION, data_type = data_types.Boolean }, + frostProofTemperature = { attribute = PRIVATE_ATTR_FROST_PROOF_TEMPERATURE, data_type = data_types.Int16, scale = 100 }, + radarSensitivity = { attribute = PRIVATE_ATTR_RADAR_SENSITIVITY, data_type = data_types.Uint8 }, + radarDoNotDisturb = { attribute = PRIVATE_ATTR_RADAR_DO_NOT_DISTURB, data_type = data_types.Boolean }, + screenWorkingBrightness = { attribute = PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS, data_type = data_types.Uint8 }, + screenStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS, data_type = data_types.Uint8 }, + nightStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS, data_type = data_types.Uint8 }, + screenNightMode = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_MODE, data_type = data_types.Boolean }, + overheatProtectTemp = { attribute = PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE, data_type = data_types.Int16, scale = 100 }, + overheatProtection = { attribute = PRIVATE_ATTR_OVERHEAT_PROTECTION, data_type = data_types.Boolean }, + radarEnabled = { attribute = PRIVATE_ATTR_RADAR_ENABLED, data_type = data_types.Boolean }, +} + +-- Builds a manufacturer-specific attribute write for the SONOFF private cluster. +local function write_private_attribute(device, attribute_id, data_type, value) + -- The vendor cluster requires the eWeLink manufacturer code on every write. + return cluster_base.write_manufacturer_specific_attribute(device, SONOFF_PRIVATE_CLUSTER, attribute_id, SONOFF_MFG_CODE, data_type, value) +end + +-- Converts a preference value into the primitive expected by its Zigbee data type. +local function preference_value(value, preference) + -- Boolean preferences are already represented as Lua booleans by the platform. + if preference.data_type == data_types.Boolean then + return value + end + -- Boolean preferences backed by numeric attributes (e.g. Uint8 0x00/0x01) must be encoded as 0/1. + if type(value) == "boolean" then + return value and 1 or 0 + end + local numeric_value = tonumber(value) + if numeric_value == nil then + return nil + end + return preference.scale and utils.round(numeric_value * preference.scale) or numeric_value +end + +-- Restricts a clock value to the device's 30-minute schedule grid. +local function normalize_schedule_minutes(value) + -- The device only accepts values from 00:00 through 23:30 at 30-minute intervals. + local numeric_value = tonumber(value) + if numeric_value == nil then + return nil + end + return utils.clamp_value(math.floor((numeric_value + 15) / 30) * 30, 0, 1410) +end + +-- Writes a paired start/end time preference as a typed Zigbee Structure. +local function write_schedule(device, attribute_id, start_value, end_value) + -- Both schedule attributes are encoded symmetrically as two Uint16 structure members. + local start_minutes = normalize_schedule_minutes(start_value) + local end_minutes = normalize_schedule_minutes(end_value) + if start_minutes == nil or end_minutes == nil then + return + end + device:send(write_private_attribute(device, attribute_id, data_types.Structure, data_types.Structure({ data_types.Uint16(start_minutes), data_types.Uint16(end_minutes) }))) +end + +-- Sends the vendor temporary-mode command using the current preference values. +local function write_temporary_mode(device) + -- Temporary mode is a cluster command rather than an attribute write. + local action_byte = ({ exit = 0, boost = 1, timer = 2 })[device.preferences.temporaryModeAction] + if action_byte == nil then + return + end + local duration_seconds = math.floor(utils.clamp_value(tonumber(device.preferences.temporaryModeDuration) or 30, 1, 1440) * 60) + local temperature = device.preferences.temporaryModeAction == "boost" and 30 or utils.clamp_value(tonumber(device.preferences.temporaryModeTemperature) or 30, 5, 30) + local temperature_centi = utils.round(temperature * 100) + local payload = string.pack("= 40 then + value = utils.f_to_c(value) + end + device:send_to_component(command.component, Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(utils.clamp_value(value, 5, 30) * 100))) +end + +local sonoff_thermostat = { + NAME = "SONOFF TP-WGZBA Thermostat Handler", + can_handle = require "sonoff.tp_wgzba.can_handle", + lifecycle_handlers = { + added = do_refresh, + doConfigure = do_configure, + infoChanged = info_changed, + }, + capability_handlers = { + [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh }, + [ThermostatHeatingSetpoint.ID] = { [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint }, + }, +} + +return sonoff_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua index 3b535c3cf0..f1f59d13a1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -3,6 +3,7 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { + lazy_load_if_possible("sonoff"), lazy_load_if_possible("zenwithin"), lazy_load_if_possible("fidure"), lazy_load_if_possible("sinope"), @@ -16,5 +17,6 @@ local sub_drivers = { lazy_load_if_possible("resideo_korea"), lazy_load_if_possible("aqara"), lazy_load_if_possible("wallhero"), + lazy_load_if_possible("sonoff.tp_wgzba"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua new file mode 100644 index 0000000000..387befbfb4 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua @@ -0,0 +1,224 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local generic_body = require "st.zigbee.generic_body" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zb_const = require "st.zigbee.constants" + +local Basic = clusters.Basic +local Thermostat = clusters.Thermostat +local MFG_CODE = 0x1286 +local PRIVATE_CLUSTER = 0xFC11 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("thermostat-sonoff.yml"), + preferences = { + temperatureCompensation = 0, + childLock = false, + btPairingBroadcast = false, + openWindowDetection = false, + frostProofTemperature = 5, + temporaryModeAction = "exit", + temporaryModeDuration = 30, + temporaryModeTemperature = 30, + temperatureThresholdLow = -2, + temperatureThresholdHigh = 2, + radarSensitivity = "2", + radarDoNotDisturb = false, + radarDoNotDisturbStart = 0, + radarDoNotDisturbEnd = 0, + screenWorkingBrightness = 5, + screenStandbyBrightness = 0, + nightStandbyBrightness = 0, + screenNightMode = false, + screenNightStart = 0, + screenNightEnd = 0, + heatRelayNormClosed = false, + boilerRelayNormClosed = false, + overheatProtectTemp = 33.5, + overheatProtection = false, + radarEnabled = false, + }, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TP-WGZBA", + server_clusters = { Basic.ID, Thermostat.ID, PRIVATE_CLUSTER }, + }, + }, +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + -- Register the device before every test so the SONOFF sub-driver is selected. + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +-- Builds the cluster-specific temporary-mode command expected from a preference update. +local function build_temporary_mode_command(payload) + -- The command uses the same manufacturer cluster endpoint as private attribute writes. + local header = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(0x11) }) + header.frame_ctrl:set_cluster_specific() + return messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, mock_device:get_short_address(), + mock_device:get_endpoint(PRIVATE_CLUSTER), zb_const.HA_PROFILE_ID, PRIVATE_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ zcl_header = header, zcl_body = generic_body.GenericBody(payload) }), + }) +end + +test.register_coroutine_test( + "SONOFF sub-driver should match only TP-WGZBA", + function() + -- The flattened handler owns the fingerprint without a nested TP-WGZBA dispatcher. + local can_handle = require "sonoff.tp_wgzba.can_handle" + assert(can_handle({}, nil, mock_device)) + local rejected = can_handle({}, nil, { + get_manufacturer = function() return "SONOFF" end, + get_model = function() return "OTHER" end, + }) + assert(not rejected) + end +) + +test.register_message_test( + "Refresh should read only standard capability attributes", + { + { channel = "capability", direction = "receive", message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:read(mock_device) } }, + } +) + +test.register_coroutine_test( + "Configure should bind and enable standard thermostat reporting", + function() + -- All visible state is now maintained by standard reporting instead of custom capabilities. + test.socket.zigbee:__set_channel_ordering("relaxed") + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ "off", "auto", "heat" }, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Thermostat.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperature:configure_reporting(mock_device, 10, 300, 10) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 10, 300, 50) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 300) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.ThermostatRunningState:configure_reporting(mock_device, 10, 300, 1) }) + end +) + +test.register_coroutine_test( + "Changing preferences should write typed private attributes and structures", + function() + -- Settings are sent through infoChanged rather than custom capability commands. + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { openWindowDetection = true, temperatureThresholdLow = -1.5, temperatureThresholdHigh = 1.5 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x6000, MFG_CODE, data_types.Boolean, true) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x601F, MFG_CODE, data_types.Structure, + data_types.Structure({ data_types.Int16(-150), data_types.Int16(150) })) + }) + end +) + +test.register_coroutine_test( + "Bluetooth pairing broadcast preference should write the Uint8 private attribute", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { btPairingBroadcast = true }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x0029, MFG_CODE, data_types.Uint8, 1) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { btPairingBroadcast = false }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x0029, MFG_CODE, data_types.Uint8, 0) }) + end +) + +test.register_coroutine_test( + "Temperature compensation preference should write LocalTemperatureCalibration in 0.1 degree units", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = -10 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, -100) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = 10 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, 100) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = 2.3 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, 23) }) + end +) + +test.register_coroutine_test( + "Temporary-mode preference should send the vendor cluster command", + function() + -- Timer mode carries the selected duration and temperature as little-endian values. + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temporaryModeAction = "timer", temporaryModeDuration = 45, temporaryModeTemperature = 28.5 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, build_temporary_mode_command("\x02\x8C\x0A\x00\x00\x22\x0B") }) + end +) + +test.register_coroutine_test( + "Setting thermostat mode should use the base driver write and readback", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.thermostatMode.ID, component = "main", command = "auto", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.AUTO) }) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) }) + end +) + +test.register_message_test( + "Decoded standard reports should publish standard thermostat events", + { + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2350) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 23.5, unit = "C" })) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2150) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = "C" })) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.AUTO) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()) }, + } +) + +-- TP-WGZBA firmware sets ThermostatRunningState to 0x0001 (Heat State On) while the relay is on and 0x0000 otherwise. +test.register_message_test( + "Running state reports should map the heat bit to heating and zero to idle", + { + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0001) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0000) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.idle()) }, + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua new file mode 100644 index 0000000000..150ad7bfe2 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -0,0 +1,429 @@ +-- 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 cluster_base = require "st.zigbee.cluster_base" +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 messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local zb_const = require "st.zigbee.constants" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +local WORK_MODE_ATTR = 0x0018 +local BUTTON_COMMAND = 0x10 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("thermostat-sonoff-trv-zbt.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TRV-ZBT", + server_clusters = { 0x0001, 0x0201, 0x0402, SONOFF_CLUSTER }, + }, + }, +}) + +zigbee_test_utils.prepare_zigbee_env_info() +test.set_test_init_function(function() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "auto" }, { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 5, maximum = 30, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end +end) + +local function bluetooth_pairing_command(device) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(BUTTON_COMMAND), + }) + zcl_header.frame_ctrl:set_cluster_specific() + + return messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + }) +end + +test.register_message_test( + "SONOFF TRV-ZBT reports local temperature", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2150) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = "C" })), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT reports setpoint and battery percentage", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2250) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.5, unit = "C" })), + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 146) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(73)), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT maps standard and proprietary modes", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.HEAT) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()), + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_CLUSTER, { + { WORK_MODE_ATTR, data_types.Uint8.ID, 0x04 }, + }), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()), + }, + }, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes setpoints in Celsius and Fahrenheit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 21.5 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2150), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = "C" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 68 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2000), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 20.0, unit = "C" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 64 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1750), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 17.5, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT updates setpoint range from reported limits", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 1200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 30, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 22.0, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 25 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.0, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT starts Bluetooth pairing when the preference is enabled", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = true }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, bluetooth_pairing_command(mock_device) }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = false }, + })) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes the child lock preference", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 1 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(true, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 0 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(false, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes thermostat mode and refreshes required attributes", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {}, + named_args = { mode = "auto" }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.AUTO), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT configures reporting and cluster bindings", + function() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, Thermostat.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, PowerConfiguration.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, TemperatureMeasurement.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperature:configure_reporting(mock_device, 10, 300, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 1, 600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:configure_reporting(mock_device, 1, 600, 1), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 600, 100), + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:read(mock_device), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:read(mock_device), + }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-valve/fingerprints.yml b/drivers/SmartThings/zigbee-valve/fingerprints.yml index 6c9678452d..832ff016a2 100644 --- a/drivers/SmartThings/zigbee-valve/fingerprints.yml +++ b/drivers/SmartThings/zigbee-valve/fingerprints.yml @@ -39,5 +39,33 @@ zigbeeManufacturer : manufacturer: Compacta model: ZBVC1(1023A) deviceProfileName: valve-battery-powerSource - - + - id: SONOFF/SWV-ZFE + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZFE + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZFU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZFU + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZNE + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNE + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZNU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNU + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZF2U + deviceLabel: SONOFF Water Valve 2 + manufacturer: SONOFF + model: SWV-ZF2U + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZF2 + deviceLabel: SONOFF Water Valve 2 + manufacturer: SONOFF + model: SWV-ZF2 + deviceProfileName: valve-battery diff --git a/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml b/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml new file mode 100644 index 0000000000..9aa59a8482 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml @@ -0,0 +1,14 @@ +name: valve-battery +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories : + - name: WaterValve diff --git a/drivers/SmartThings/zigbee-valve/profiles/valve.yml b/drivers/SmartThings/zigbee-valve/profiles/valve.yml new file mode 100644 index 0000000000..4503c347fe --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/profiles/valve.yml @@ -0,0 +1,10 @@ +name: valve +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterValve diff --git a/drivers/SmartThings/zigbee-valve/src/init.lua b/drivers/SmartThings/zigbee-valve/src/init.lua index 717012e2bb..b3e250cbd3 100644 --- a/drivers/SmartThings/zigbee-valve/src/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/init.lua @@ -1,7 +1,6 @@ -- Copyright 2022 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua new file mode 100644 index 0000000000..18174e338a --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua @@ -0,0 +1,22 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "SONOFF", model = "SWV-ZF2U" }, + { mfr = "SONOFF", model = "SWV-ZF2" }, + { mfr = "SONOFF", model = "SWV-ZFU" }, + { mfr = "SONOFF", model = "SWV-ZFE" }, + { mfr = "SONOFF", model = "SWV-ZNU" }, + { mfr = "SONOFF", model = "SWV-ZNE" } +} + +local function sonoff_can_handle(opts, driver, device, ...) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require "sonoff" + end + end + return false +end + +return sonoff_can_handle diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua new file mode 100644 index 0000000000..dd2e6f641b --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua @@ -0,0 +1,123 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local Identify = zcl_clusters.Identify +local OnOff = zcl_clusters.OnOff +local PowerConfiguration = zcl_clusters.PowerConfiguration +local st_device = require "st.device" + +local CONFIGURATION = { + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 1 + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnOff.ID, + minimum_interval = 0, + maximum_interval = 600, + data_type = OnOff.attributes.OnOff.base_type + } +} + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end + +local function is_dual_channel(device) + local model = device:get_model() + return model == "SWV-ZF2U" or model == "SWV-ZF2" +end + +--- Map OnOff reports to the corresponding valve. +local function onoff_attr_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + local target = device + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE and is_dual_channel(device) and ep == 0x02 then + target = find_child(device, 2) + if not target then return end + end + if value.value ~= false and value.value ~= 0 then + target:emit_event(capabilities.valve.valve.open()) + else + target:emit_event(capabilities.valve.valve.closed()) + end +end + +local function device_init(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + if is_dual_channel(device) then + device:set_find_child(find_child) + end + for _, attribute in ipairs(CONFIGURATION) do + device:add_configured_attribute(attribute) + end + end +end + +local function do_configure(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + device:refresh() + device:configure() + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE and is_dual_channel(device) then + if find_child(device, 2) == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + label = string.format("%s 2", device.label), + profile = "valve", + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", 2), + vendor_provided_label = string.format("%s 2", device.label), + }) + end + end + device:refresh() +end + +local function driver_switched(driver, device) + device_added(driver, device) +end + +--- A device button press prompts a refresh of both SWV2C channels. +local function identify_handler(driver, device, zb_rx) + if not is_dual_channel(device) then return end + device.thread:call_with_delay(2, function() + device:send(OnOff.attributes.OnOff:read(device)) + device:send(OnOff.attributes.OnOff:read(device):to_endpoint(0x02)) + end) +end + +local sonoff_valve_handler = { + NAME = "SONOFF Water Valve Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + driverSwitched = driver_switched, + }, + zigbee_handlers = { + attr = { + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = onoff_attr_handler + } + }, + cluster = { + [Identify.ID] = { + [Identify.server.commands.Identify.ID] = identify_handler + } + } + }, + can_handle = require "sonoff.can_handle", +} + +return sonoff_valve_handler diff --git a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua index 258579c7fb..649c5fc898 100644 --- a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua @@ -5,5 +5,6 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { lazy_load_if_possible("sinope"), lazy_load_if_possible("ezex"), + lazy_load_if_possible("sonoff"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua new file mode 100644 index 0000000000..db51025d52 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua @@ -0,0 +1,263 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local Identify = clusters.Identify +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("valve-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SWV-ZFE", + server_clusters = {0x0000, 0x0001, 0x0006, 0x0404, 0xFC11} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "OnOff(on) should set valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +local uint8_onoff_off_report = OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) +uint8_onoff_off_report.body.zcl_body.attr_records[1].data_type.value = data_types.Uint8.ID + +test.register_message_test( + "OnOff(off) report with Uint8 data type should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, uint8_onoff_off_report } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + } +) + +test.register_message_test( + "Identify command should not trigger dual-channel reads on SWV1C", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Identify.server.commands.Identify.build_test_rx(mock_device, 1) } + } + } +) + +test.register_coroutine_test( + "Init lifecycle should use default battery percentage reporting configuration", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + 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.BatteryPercentageRemaining: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, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Capability(valve) command(open) should send OnOff.On", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device) } + } + } +) + +test.register_message_test( + "Capability(valve) command(close) should send OnOff.Off", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device) } + } + } +) + +test.register_coroutine_test( + "doConfigure lifecycle should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + 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.BatteryPercentageRemaining: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, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +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, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_message_test( + "Device added event should refresh device states", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_device.id, "added" }, + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua new file mode 100644 index 0000000000..63dd94d209 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua @@ -0,0 +1,405 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local Identify = clusters.Identify +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +-- Parent device (endpoint 1): valve + battery + firmwareUpdate + refresh +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("valve-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SWV-ZF2U", + server_clusters = { 0x0000, 0x0001, 0x0006, 0x0404, 0xFC11 } + }, + [2] = { + id = 2, + manufacturer = "SONOFF", + model = "SWV-ZF2U", + server_clusters = { 0x0006 } + } + } + } +) + +-- Child device (endpoint 2): valve + refresh only; no battery +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("valve.yml"), + device_network_id = string.format("%04X:%02X", mock_device:get_short_address(), 2), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +local function test_init_without_child() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +-- ============================================================================ +-- Parent device (endpoint 1) tests: OnOff attribute reports -> valve events +-- ============================================================================ + +test.register_message_test( + "OnOff(on) should set valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +-- ============================================================================ +-- Child device (endpoint 2) tests: OnOff attribute reports -> child valve events +-- ============================================================================ + +test.register_message_test( + "OnOff(on) from endpoint 2 should set child valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_child.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) from endpoint 2 should set child valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_child.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x02) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +test.register_message_test( + "Endpoint 2 report on parent should update child valve", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "Endpoint 2 report should not update the parent before child creation", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) + } + } + }, + { test_init = test_init_without_child } +) + +-- ============================================================================ +-- Parent device battery tests +-- ============================================================================ + +-- Battery percentage +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + } +) + +test.register_coroutine_test( + "Button Identify command should refresh both valve channels", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + Identify.server.commands.Identify.build_test_rx(mock_device, 1) + }) + test.wait_for_events() + + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + end +) + +-- ============================================================================ +-- Parent device (endpoint 1) valve command tests +-- ============================================================================ + +test.register_message_test( + "Capability(valve) command(open) should send OnOff.On", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device) } + } + } +) + +test.register_message_test( + "Capability(valve) command(close) should send OnOff.Off", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device) } + } + } +) + +-- ============================================================================ +-- Child device (endpoint 2) valve command tests +-- ============================================================================ + +test.register_message_test( + "Child valve command(open) should send OnOff.On to endpoint 2", + { + { + channel = "capability", + direction = "receive", + message = { mock_child.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) } + } + } +) + +test.register_message_test( + "Child valve command(close) should send OnOff.Off to endpoint 2", + { + { + channel = "capability", + direction = "receive", + message = { mock_child.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) } + } + } +) + +-- ============================================================================ +-- doConfigure lifecycle tests +-- ============================================================================ + +test.register_coroutine_test( + "doConfigure lifecycle should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + 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.BatteryPercentageRemaining: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, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID, 2):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0):to_endpoint(0x02) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +-- ============================================================================ +-- Refresh tests +-- ============================================================================ + +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, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +-- ============================================================================ +-- Device added lifecycle tests: child-device creation and refresh +-- ============================================================================ + +test.register_coroutine_test( + "added lifecycle should create child device and refresh parent states", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + -- Verify child-device creation + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("%s 2", mock_device.label), + profile = "valve", + parent_device_id = mock_device.id, + parent_assigned_child_key = "02" + }) + + -- Verify that refresh reads parent-device attributes + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { test_init = test_init_without_child } +) + +test.run_registered_tests()