From 8c73ff2a4434c29ee0e873f06e6e8beeac6d9568 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:31:53 -0500 Subject: [PATCH 01/10] update new lifx fingerprints to include colorControl (#3034) --- .../matter-switch/fingerprints.yml | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 47409926af..e4602b4178 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -1351,172 +1351,172 @@ matterManufacturer: deviceLabel: LIFX Supercolor (A19) vendorId: 0x1423 productId: 0x00A3 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/118" deviceLabel: LIFX Lightstrip vendorId: 0x1423 productId: 0x0076 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/221" deviceLabel: LIFX Spot vendorId: 0x1423 productId: 0x00DD - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/144" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x0090 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/216" deviceLabel: LIFX Candle Color (B10) vendorId: 0x1423 productId: 0x00D8 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/225" deviceLabel: LIFX PAR38 vendorId: 0x1423 productId: 0x00E1 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/186" deviceLabel: LIFX Candle Color vendorId: 0x1423 productId: 0x00BA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/202" deviceLabel: LIFX Ceiling 13x26 vendorId: 0x1423 productId: 0x00CA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/143" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x008F - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/166" deviceLabel: LIFX Supercolour (BR30) vendorId: 0x1423 productId: 0x00A6 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/167" deviceLabel: LIFX Downlight vendorId: 0x1423 productId: 0x00A7 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/207" deviceLabel: LIFX Everyday Lightstrip vendorId: 0x1423 productId: 0x00CF - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/222" deviceLabel: LIFX Path (Round) vendorId: 0x1423 productId: 0x00DE - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/203" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x00CB - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/218" deviceLabel: LIFX Tube vendorId: 0x1423 productId: 0x00DA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/214" deviceLabel: LIFX Permanent Outdoor vendorId: 0x1423 productId: 0x00D6 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/117" deviceLabel: LIFX Lightstrip vendorId: 0x1423 productId: 0x0075 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/223" deviceLabel: LIFX Downlight (6 Retro Downlight) vendorId: 0x1423 productId: 0x00DF - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/224" deviceLabel: LIFX Downlight (90mm Downlight) vendorId: 0x1423 productId: 0x00E0 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/204" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x00CC - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/206" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x00CE - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/164" deviceLabel: LIFX Supercolor (BR30) vendorId: 0x1423 productId: 0x00A4 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/120" deviceLabel: LIFX Beam vendorId: 0x1423 productId: 0x0078 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/208" deviceLabel: LIFX Everyday Lightstrip vendorId: 0x1423 productId: 0x00D0 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/165" deviceLabel: LIFX Supercolour (A19) vendorId: 0x1423 productId: 0x00A5 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/142" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x008E - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/141" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x008D - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/177" deviceLabel: LIFX Ceiling vendorId: 0x1423 productId: 0x00B1 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/170" deviceLabel: LIFX Supercolour (A21) vendorId: 0x1423 productId: 0x00AA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/205" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x00CD - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/265" deviceLabel: Ceiling 13 vendorId: 0x1423 productId: 0x0109 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/266" deviceLabel: LIFX Ceiling 13 vendorId: 0x1423 productId: 0x010A - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/267" deviceLabel: LIFX Mirror vendorId: 0x1423 productId: 0x010B - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/268" deviceLabel: LIFX Mirror vendorId: 0x1423 productId: 0x010C - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level #LG - id: "4142/8784" From b4d10a664874c1c93cd2c73e752e18f3dbda1d99 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:39:40 -0500 Subject: [PATCH 02/10] persist electrical tags that are used for profiling (#3038) --- drivers/SmartThings/matter-switch/src/switch_utils/utils.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 8e21c8baf8..d2457bfb4d 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -431,7 +431,7 @@ function utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sens table.sort(associated_endpoint_ids) local primary_associated_ep_id = associated_endpoint_ids[1] -- map the required electrical tags for this electrical sensor EP with the first associated EP ID, used later during profling. - utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags) + utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags, {persist = true}) utils.set_field_for_endpoint(device, fields.ASSIGNED_CHILD_KEY, electrical_sensor_ep.endpoint_id, string.format("%d", primary_associated_ep_id), { persist = true }) return true end From fa41aa63b5f45cb43c3c8627e2571c1025ed0fc5 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:39:53 -0500 Subject: [PATCH 03/10] Add Initial FP400 profile and subdriver (#3024) --- .../matter-sensor/fingerprints.yml | 5 + .../matter-sensor/profiles/aqara-fp400.yml | 14 ++ .../matter-sensor/src/sensor_utils/fields.lua | 6 + .../matter-sensor/src/sensor_utils/utils.lua | 11 ++ .../matter-sensor/src/sub_drivers.lua | 1 + .../sub_drivers/aqara_fp400/can_handle.lua | 12 ++ .../src/sub_drivers/aqara_fp400/init.lua | 23 +++ .../src/test/test_matter_aqara_fp400.lua | 141 ++++++++++++++++++ 8 files changed, 213 insertions(+) create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index 483b92fc21..45b21dd386 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -15,6 +15,11 @@ matterManufacturer: vendorId: 0x115F productId: 0x2005 deviceProfileName: presence-illuminance-temperature-humidity-battery + - id: "4447/8201" + deviceLabel: Spatial Multi-Sensor FP400 + vendorId: 0x115F + productId: 0x2009 + deviceProfileName: aqara-fp400 #Bosch - id: 4617/12309 deviceLabel: "Door/window contact II [M]" diff --git a/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml new file mode 100644 index 0000000000..c2d5b7b037 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml @@ -0,0 +1,14 @@ +name: aqara-fp400 +components: +- id: main + capabilities: + - id: presenceSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua index b31b1b5c5b..232e431277 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua @@ -49,4 +49,10 @@ SensorFields.BOOLEAN_CAP_EVENT_MAP = { } } +SensorFields.vendor_overrides = { + [0x115F] = { -- AQARA_MANUFACTURER_ID + [0x2009] = { is_aqara_fp400 = true } + } +} + return SensorFields diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua index d5437410a5..513c9f4602 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua @@ -1,6 +1,8 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local fields = require "sensor_utils.fields" + local utils = {} -- Sanity check bounds for soil moisture measurement limits (percent) @@ -15,6 +17,15 @@ function utils.set_field_for_endpoint(device, field, endpoint, value, additional device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) end +function utils.get_product_override_field(device, override_key) + if device.manufacturer_info + and fields.vendor_overrides[device.manufacturer_info.vendor_id] + and fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id] + then + return fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id][override_key] + end +end + function utils.tbl_contains(array, value) if value == nil then return false end for _, element in pairs(array or {}) do diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua index 3bf4c46f73..aa8b046927 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua @@ -6,5 +6,6 @@ local sub_drivers = { lazy_load_if_possible("sub_drivers.air_quality_sensor"), lazy_load_if_possible("sub_drivers.smoke_co_alarm"), lazy_load_if_possible("sub_drivers.bosch_button_contact"), + lazy_load_if_possible("sub_drivers.aqara_fp400"), } return sub_drivers diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua new file mode 100644 index 0000000000..d28a66f147 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_fp400(opts, driver, device) + local sensor_utils = require "sensor_utils.utils" + if sensor_utils.get_product_override_field(device, "is_aqara_fp400") then + return true, require("sub_drivers.aqara_fp400") + end + return false +end + +return is_aqara_fp400 diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua new file mode 100644 index 0000000000..5f47c86047 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua @@ -0,0 +1,23 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local Fp400LifecycleHandlers = {} + +-- overwrite to avoid unnecessary metadata update calls +function Fp400LifecycleHandlers.do_configure() end + +-- overwrite to avoid unnecessary metadata update calls +function Fp400LifecycleHandlers.driver_switched(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +local aqara_fp400_handler = { + NAME = "aqara-fp400", + lifecycle_handlers = { + doConfigure = Fp400LifecycleHandlers.do_configure, + driverSwitched = Fp400LifecycleHandlers.driver_switched, + }, + can_handle = require("sub_drivers.aqara_fp400.can_handle"), +} + +return aqara_fp400_handler diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua new file mode 100644 index 0000000000..7f384c744e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua @@ -0,0 +1,141 @@ +-- 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.matter.clusters" +local t_utils = require "integration_test.utils" + +local matter_endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.IlluminanceMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + } +} + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqara-fp400.yml"), + manufacturer_info = { + vendor_id = 0x115F, + product_id = 0x2009, + }, + endpoints = matter_endpoints +}) + +local function subscribe_on_init(dev) + local subscribe_request = clusters.OccupancySensing.attributes.Occupancy:subscribe(dev) + subscribe_request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(dev)) + return subscribe_request +end + +local function test_init() + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = subscribe_on_init(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test no profile change on doConfigure for FP400", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + -- The FP400 sub-driver overrides doConfigure to be a no-op + -- When doConfigure completes successfully, the framework automatically provisions the device + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test no profile change on driverSwitched for FP400", + function() + local current_profile = mock_device.profile.id + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + -- The FP400 sub-driver overrides driverSwitched to only update provisioning state + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + -- Ensure profile has not changed + test.wait_for_events() + assert(mock_device.profile.id == current_profile, "Profile should not change on driverSwitched") + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Occupancy reports should generate correct presence messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 1, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 1, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Illuminance reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 21370) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() From c7fc03175413aebe8a351623f9c0807598b10770 Mon Sep 17 00:00:00 2001 From: thinkaName <144081204+thinkaName@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:28:30 +0800 Subject: [PATCH 04/10] add firstled-io_M4S4BAC (#2971) --- .../zigbee-switch/fingerprints.yml | 6 + .../switch-button-light-restore-wireless.yml | 39 ++ .../profiles/switch-button-wireless.yml | 17 + .../switch-light-restore-wireless.yml | 37 ++ .../profiles/switch-wireless.yml | 15 + .../src/firstled-io/can_handle.lua | 12 + .../src/firstled-io/fingerprints.lua | 9 + .../zigbee-switch/src/firstled-io/init.lua | 209 ++++++++ .../zigbee-switch/src/sub_drivers.lua | 3 +- .../src/test/test_firstled_switch.lua | 481 ++++++++++++++++++ tools/localizations/cn.csv | 1 + 11 files changed, 828 insertions(+), 1 deletion(-) create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index 90862aa61f..b44a13d3c1 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -2451,6 +2451,12 @@ zigbeeManufacturer: manufacturer: JNL model: Y-K002-001 deviceProfileName: basic-switch + #FIRSTLED + - id: "FIRSTLED/M4S4BAC" + deviceLabel: Mirror Series 4x4 1 + manufacturer: FIRSTLED + model: M4S4BAC + deviceProfileName: switch-light-restore-wireless zigbeeGeneric: - id: "genericSwitch" deviceLabel: Zigbee Switch diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml new file mode 100644 index 0000000000..f98b552b87 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml @@ -0,0 +1,39 @@ +name: switch-button-light-restore-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - title: "背光灯(backlight/백라이트)" + name: backlight + description: "背光灯(backlight/백라이트)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "人体接近感应(proximity detection/근접 감지)" + default: 2 + - title: "开关上电状态(relay powerOn state)" + name: powerOnStatus + description: "开关上电状态(relay powerOn state/릴레이 초기 동작 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 2 + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml new file mode 100644 index 0000000000..c48290e1a8 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml @@ -0,0 +1,17 @@ +name: switch-button-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml new file mode 100644 index 0000000000..b2e002d690 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml @@ -0,0 +1,37 @@ +name: switch-light-restore-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - title: "背光灯(backlight/백라이트)" + name: backlight + description: "背光灯(backlight/백라이트)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "人体接近感应(proximity detection/근접 감지)" + default: 2 + - title: "开关上电状态(relay powerOn state)" + name: powerOnStatus + description: "开关上电状态(relay powerOn state/릴레이 초기 동작 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 2 + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml new file mode 100644 index 0000000000..cb4be470f9 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml @@ -0,0 +1,15 @@ +name: switch-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua new file mode 100644 index 0000000000..5ad2d32a4b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/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("firstled-io.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("firstled-io") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua new file mode 100644 index 0000000000..2586782642 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--The number of children determines the number of sub-devices to be created. Each sub-device has the capability to switch between a switch and a button. +--The number of buttons determines how many buttons devices will be created. +--The driver supports a series of device combinations, such as 4+4, 3+3, 2+2, 4+0, etc., of switch and button type products. +return { + { mfr = "FIRSTLED", model = "M4S4BAC", children = 4, buttons = 4, child_profile = "switch-wireless" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua new file mode 100644 index 0000000000..6348f254b2 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua @@ -0,0 +1,209 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local device_lib = require "st.device" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local Scenes = zcl_clusters.Scenes +local PRIVATE_CLUSTER_ID = 0xFCCA +local MFG_CODE = 0x1235 +local FINGERPRINTS = require("firstled-io.fingerprints") + +--stse.changeToWirelessSwitch +--switch mode:The local switch and the app can control the relay.The button capability is not working. +--wirelessSwitch mode:The local switch does not control the relay. Once triggered, it will report to the system as "RecallScene",emit_event button.pushed. The relay can be controlled via the app. +local preference_map = { + ["backlight"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0000, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + }, + ["powerOnStatus"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0001, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + }, + ["stse.changeToWirelessSwitch"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0002, + mfg_code = MFG_CODE, + data_type = data_types.Boolean + } +} + +local function is_parent_device(device) + local parent = device:get_parent_device() + return parent == nil +end + +--If it is in the switch mode, only the switch will be displayed. If it is in the wirelessSwitch mode, both the switch and the button will be displayed. +local function toggle_button_visibility(device, show) + local is_parent = is_parent_device(device) + if is_parent then + if show then + device:try_update_metadata({profile = "switch-button-light-restore-wireless"}) + else + device:try_update_metadata({profile = "switch-light-restore-wireless"}) + end + else + if show then + device:try_update_metadata({profile = "switch-button-wireless"}) + else + device:try_update_metadata({profile = "switch-wireless"}) + end + end +end + +--When stse.changeToWirelessSwitch switching to the wirelessSwitch mode to listen for profile changes +local function listen_profile_button_transition(device, args) + local current_has = device:supports_capability(capabilities.button, "main") + + local old_has = false + if args and args.old_st_store and args.old_st_store.profile then + local old_main = args.old_st_store.profile.components.main + if old_main and old_main.capabilities then + old_has = old_main.capabilities["button"] ~= nil + end + end + --Capabilities button from non-existent to existing + if not old_has and current_has then + device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = {displayed = false}})) + device:emit_event(capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}})) + end +end + +local function device_info_changed(driver, device, event, args) + listen_profile_button_transition(device, args) + + local preferences = device.preferences + local old_preferences = args.old_st_store.preferences + if preferences ~= nil then + for id, attr in pairs(preference_map) do + local old_value = old_preferences[id] + local value = preferences[id] + if value ~= nil and value ~= old_value then + if attr.data_type == data_types.Uint8 then + value = tonumber(value) + end + device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, + attr.mfg_code, attr.data_type, value)) + --Switch to the corresponding profile based on stse.changeToWirelessSwitch + if id == "stse.changeToWirelessSwitch" then + toggle_button_visibility(device, value) + end + end + end + end +end + +local function get_children_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.children + end + end +end + +local function get_button_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.buttons + end + end +end + +local function get_child_profile_name(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.child_profile + end + end +end + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end + +--Create composite switches such as 4+4, 1+1, 4+0, 3+0 children+button +local function device_added(driver, device) + -- Create the corresponding number of child devices based on the value of fingerprint.children + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + local children_amount = get_children_amount(device) + if children_amount >= 2 then + for i = 2, children_amount, 1 do + if find_child(device, i) == nil then + local name = string.format("%s%d", string.sub(device.label, 0, -2), i) + local child_profile = get_child_profile_name(device) + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + -- Create the corresponding number of button devices based on the value of fingerprint.buttons + local button_amount = get_button_amount(device) + if button_amount >= 1 then + for i = children_amount + 1, children_amount + button_amount, 1 do + if find_child(device, i) == nil then + local name = string.format("%s%d", string.sub(device.label, 0, -2), i) + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = "button", + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name, + } + driver:try_create_device(metadata) + end + end + end + elseif device.network_type == "DEVICE_EDGE_CHILD" then + device:emit_event(capabilities.button.numberOfButtons({ value = 1 }, + { visibility = { displayed = false } })) + device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } })) + end +end + +local function scenes_cluster_handler(driver, device, zb_rx) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.button.button.pushed({ state_change = true })) +end + +local function device_init(self, device) + -- for multiple switch + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + device:set_find_child(find_child) + end +end + +local firstled_switch_handler = { + NAME = "FIRSTLED Switch Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = device_info_changed + }, + zigbee_handlers = { + cluster = { + [Scenes.ID] = { + [Scenes.server.commands.RecallScene.ID] = scenes_cluster_handler, + } + } + }, + can_handle = require("firstled-io.can_handle"), +} + +return firstled_switch_handler diff --git a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua index 69be094da4..b29c746d71 100644 --- a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua @@ -36,5 +36,6 @@ return { lazy_load_if_possible("frient"), lazy_load_if_possible("frient-IO"), lazy_load_if_possible("color_temp_range_handlers"), - lazy_load_if_possible("stateless_handlers") + lazy_load_if_possible("stateless_handlers"), + lazy_load_if_possible("firstled-io") } diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua new file mode 100644 index 0000000000..3a3c4782ea --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua @@ -0,0 +1,481 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local frameCtrl = require "st.zigbee.zcl.frame_ctrl" +local device_lib = require "st.device" + +local OnOff = clusters.OnOff +local Scenes = clusters.Scenes + +local PRIVATE_CLUSTER_ID = 0xFCCA +local MFG_CODE = 0x1235 +local FINGERPRINTS = require("firstled-io.fingerprints") + +local parent_profile = t_utils.get_profile_definition("switch-button-light-restore-wireless.yml") +local child_switch_profile = t_utils.get_profile_definition("switch-button-wireless.yml") + +local function get_children_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.children + end + end +end + +local function get_button_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.buttons + end + end +end + +local function get_child_profile_name(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.child_profile + end + end +end + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end +-- ====================== Mock Devices ====================== +local mock_parent = test.mock_device.build_test_zigbee_device({ + profile = parent_profile, + manufacturer = "FIRSTLED", + model = "M4S4BAC", + label = "Mirror Series 4x4 1", + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { id = 1, manufacturer = "FIRSTLED", model = "M4S4BAC", server_clusters = { 0x0004, 0x0006 } } + } +}) + +local mock_children = {} + +for i = 2, 4 do + local name = string.format("%s%d", string.sub("Mirror Series 4x4 1", 0, -2), i) + table.insert(mock_children, test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + profile = child_switch_profile, + label = name, + device_network_id = string.format("%04X:%02X", mock_parent:get_short_address(), i), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name + })) +end + +local function test_init() + test.mock_device.add_test_device(mock_parent) + for _, child in ipairs(mock_children) do + test.mock_device.add_test_device(child) + end +end + +test.set_test_init_function(test_init) + +-- ====================== can_handle ====================== +test.register_coroutine_test("can_handle should return true and handler for matching device", function() + local can_handle = require("firstled-io.can_handle") + local result, handler = can_handle({}, nil, mock_parent) + assert(result == true) + assert(handler ~= nil) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("can_handle should return false for non-matching device", function() + local can_handle = require("firstled-io.can_handle") + local non_match = test.mock_device.build_test_zigbee_device({ + manufacturer = "OTHER", model = "OTHER", profile = parent_profile + }) + local result = can_handle({}, nil, non_match) + assert(result == false) + end, + { + min_api_version = 19 + } +) + +-- ====================== Lifecycle ====================== +test.register_coroutine_test("device_init should set find_child for parent", function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + end, + { + min_api_version = 19 + } +) + +-- ====================== device_added ====================== +test.register_coroutine_test("device_added - Zigbee Parent should create children and emit capabilities", function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "added"}) + if mock_parent.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + local children_amount = get_children_amount(mock_parent) + if children_amount >= 2 then + for i = 2, children_amount, 1 do + if find_child(mock_parent, i) == nil then + local name = string.format("%s%d", string.sub(mock_parent.label, 0, -2), i) + local expected_metadata = { + type = "EDGE_CHILD", + label = name, + profile = get_child_profile_name(mock_parent), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + } + mock_parent:expect_device_create(expected_metadata) + end + end + end + local button_amount = get_button_amount(mock_parent) + if button_amount >= 1 then + for i = children_amount + 1, children_amount + button_amount, 1 do + if find_child(mock_parent, i) == nil then + local name = string.format("%s%d", string.sub(mock_parent.label, 0, -2), i) + local expected_metadata = { + type = "EDGE_CHILD", + label = name, + profile = "button", + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + } + mock_parent:expect_device_create(expected_metadata) + end + end + end + + elseif mock_parent.network_type == "DEVICE_EDGE_CHILD" then + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end + end, + { + min_api_version = 19 + } +) + +local function test_device_added_child(ep, name) + test.register_coroutine_test(name, function() + local child = mock_children[ep-1] + test.socket.device_lifecycle:__queue_receive({child.id, "added"}) + + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end, + { + min_api_version = 19 + } +) +end + +for ep = 2, 4 do + test_device_added_child(ep, "test_device_added_child endpoint " .. ep) +end + +-- ====================== Preferences ====================== +test.register_coroutine_test("infoChanged - backlight 0", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "0" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - backlight 1", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "1" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - backlight 2", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "2" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 0", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "0" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "1" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 2", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "2" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - stse.changeToWirelessSwitch true", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = true }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, true) + }) + mock_parent:expect_metadata_update({ profile = "switch-button-light-restore-wireless" }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - stse.changeToWirelessSwitch false", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = false }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, false) + }) + mock_parent:expect_metadata_update({ profile = "switch-light-restore-wireless" }) + end, + { + min_api_version = 19 + } +) + +local function test_child_changeToWirelessSwitch_true(ep, name) + test.register_coroutine_test(name, function() + test.socket.device_lifecycle:__queue_receive(mock_children[ep]:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = true }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, true):to_endpoint(ep+1) + }) + mock_children[ep]:expect_metadata_update({ profile = "switch-button-wireless" }) + end, + { + min_api_version = 19 + } + ) +end + +local function test_child_changeToWirelessSwitch_false(ep, name) + test.register_coroutine_test(name, function() + test.socket.device_lifecycle:__queue_receive(mock_children[ep]:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = false }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, false):to_endpoint(ep+1) + }) + mock_children[ep]:expect_metadata_update({ profile = "switch-wireless" }) + end, + { + min_api_version = 19 + } + ) +end + +for ep = 1, 3 do + test_child_changeToWirelessSwitch_true(ep, "children infoChanged - stse.changeToWirelessSwitch true " .. ep + 1) +end + +for ep = 1, 3 do + test_child_changeToWirelessSwitch_false(ep, "children infoChanged - stse.changeToWirelessSwitch false " .. ep + 1) +end + +-- ====================== Commands ====================== +test.register_message_test("Parent device - On command", { + { channel = "device_lifecycle", direction = "receive", message = { mock_parent.id, "init" }}, + { channel = "capability", direction = "receive", message = { mock_parent.id, { capability = "switch", component = "main", command = "on", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_parent.id, capability_id = "switch", capability_cmd_id = "on" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.On(mock_parent):to_endpoint(0x01) }} + }, + { + min_api_version = 19 + } +) + +test.register_message_test("Parent device - Off command", { + { channel = "capability", direction = "receive", message = { mock_parent.id, { capability = "switch", component = "main", command = "off", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_parent.id, capability_id = "switch", capability_cmd_id = "off" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.Off(mock_parent):to_endpoint(0x01) }} + }, + { + min_api_version = 19 + } +) + +-- ====================== Attribute Reports ====================== +test.register_coroutine_test( + "OnOff report on parent endpoint", + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, true):from_endpoint(0x01) + test.socket.zigbee:__queue_receive({mock_parent.id, report}) + + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", capabilities.switch.switch.on())) + mock_parent:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "OnOff report off parent endpoint", + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, false):from_endpoint(0x01) + test.socket.zigbee:__queue_receive({mock_parent.id, report}) + + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", capabilities.switch.switch.off())) + mock_parent:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } +) + +local function test_on_cmd(ep, name) + test.register_message_test(name, { + { channel = "capability", direction = "receive", message = { mock_children[ep].id, { capability = "switch", component = "main", command = "on", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_children[ep].id, capability_id = "switch", capability_cmd_id = "on" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.On(mock_parent):to_endpoint(ep+1) }} + }, + { + min_api_version = 19 + }) +end + +local function test_off_cmd(ep, name) + test.register_message_test(name, { + { channel = "capability", direction = "receive", message = { mock_children[ep].id, { capability = "switch", component = "main", command = "off", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_children[ep].id, capability_id = "switch", capability_cmd_id = "off" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.Off(mock_parent):to_endpoint(ep+1) }} + }, + { + min_api_version = 19 + }) +end + +local function test_onoff_report_on_cmd(ep, name) + test.register_coroutine_test( + name, + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, true):from_endpoint(ep+1) + test.socket.zigbee:__queue_receive({mock_children[ep].id, report}) + + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", capabilities.switch.switch.on())) + mock_children[ep]:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } + ) +end + +local function test_onoff_report_off_cmd(ep, name) + test.register_coroutine_test( + name, + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, false):from_endpoint(ep+1) + test.socket.zigbee:__queue_receive({mock_children[ep].id, report}) + + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", capabilities.switch.switch.off())) + mock_children[ep]:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } + ) +end + +-- ====================== RecallScene ====================== +local function test_recall_scene(ep, name) + test.register_coroutine_test(name, function() + local cmd = Scenes.server.commands.RecallScene.build_test_rx(mock_parent, 0xF0F0, ep) + cmd.body.zcl_header.frame_ctrl = frameCtrl(0x11) + test.socket.zigbee:__queue_receive({ mock_parent.id, cmd }) + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 19 + }) +end + +local function test_child_recall_scene(ep, name) + test.register_coroutine_test(name, function() + local cmd = Scenes.server.commands.RecallScene.build_test_rx(mock_parent, 0xF0F0, ep + 1) + cmd.body.zcl_header.frame_ctrl = frameCtrl(0x11) + test.socket.zigbee:__queue_receive({ mock_children[ep].id, cmd }) + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 19 + }) +end + +for ep = 1, 1 do + test_recall_scene(ep, "RecallScene on parent endpoint " .. ep) +end + +for ep = 1, 3 do + test_child_recall_scene(ep, "test_child_recall_scene on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_on_cmd(ep, "children test_on_cmd on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_off_cmd(ep, "children test_off_cmd on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_onoff_report_on_cmd(ep, "children test_onoff_report_on_cmd endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_onoff_report_off_cmd(ep, "children test_onoff_report_off_cmd endpoint " .. ep + 1) +end + +test.run_registered_tests() diff --git a/tools/localizations/cn.csv b/tools/localizations/cn.csv index a67fdfeddf..d1a3ed3369 100644 --- a/tools/localizations/cn.csv +++ b/tools/localizations/cn.csv @@ -140,3 +140,4 @@ Aqara Wireless Mini Switch T1,Aqara 无线开关 T1 "MultiIR Smart button MIR-SO100",麦乐克智能按钮MIR-SO100 "MultiIR Smoke Detector MIR-SM200",麦乐克烟雾报警器MIR-SM200 "MultiIR Siren MIR-SR100",麦乐克声光报警器MIR-SR100 +"Mirror Series 4x4 1",镜系列4x4 1 From 5f1eb231178c89c60692569c5f1db2450859ece5 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:31:53 -0500 Subject: [PATCH 05/10] update new lifx fingerprints to include colorControl (#3034) --- .../matter-switch/fingerprints.yml | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 47409926af..e4602b4178 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -1351,172 +1351,172 @@ matterManufacturer: deviceLabel: LIFX Supercolor (A19) vendorId: 0x1423 productId: 0x00A3 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/118" deviceLabel: LIFX Lightstrip vendorId: 0x1423 productId: 0x0076 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/221" deviceLabel: LIFX Spot vendorId: 0x1423 productId: 0x00DD - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/144" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x0090 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/216" deviceLabel: LIFX Candle Color (B10) vendorId: 0x1423 productId: 0x00D8 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/225" deviceLabel: LIFX PAR38 vendorId: 0x1423 productId: 0x00E1 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/186" deviceLabel: LIFX Candle Color vendorId: 0x1423 productId: 0x00BA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/202" deviceLabel: LIFX Ceiling 13x26 vendorId: 0x1423 productId: 0x00CA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/143" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x008F - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/166" deviceLabel: LIFX Supercolour (BR30) vendorId: 0x1423 productId: 0x00A6 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/167" deviceLabel: LIFX Downlight vendorId: 0x1423 productId: 0x00A7 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/207" deviceLabel: LIFX Everyday Lightstrip vendorId: 0x1423 productId: 0x00CF - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/222" deviceLabel: LIFX Path (Round) vendorId: 0x1423 productId: 0x00DE - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/203" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x00CB - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/218" deviceLabel: LIFX Tube vendorId: 0x1423 productId: 0x00DA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/214" deviceLabel: LIFX Permanent Outdoor vendorId: 0x1423 productId: 0x00D6 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/117" deviceLabel: LIFX Lightstrip vendorId: 0x1423 productId: 0x0075 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/223" deviceLabel: LIFX Downlight (6 Retro Downlight) vendorId: 0x1423 productId: 0x00DF - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/224" deviceLabel: LIFX Downlight (90mm Downlight) vendorId: 0x1423 productId: 0x00E0 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/204" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x00CC - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/206" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x00CE - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/164" deviceLabel: LIFX Supercolor (BR30) vendorId: 0x1423 productId: 0x00A4 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/120" deviceLabel: LIFX Beam vendorId: 0x1423 productId: 0x0078 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/208" deviceLabel: LIFX Everyday Lightstrip vendorId: 0x1423 productId: 0x00D0 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/165" deviceLabel: LIFX Supercolour (A19) vendorId: 0x1423 productId: 0x00A5 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/142" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x008E - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/141" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x008D - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/177" deviceLabel: LIFX Ceiling vendorId: 0x1423 productId: 0x00B1 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/170" deviceLabel: LIFX Supercolour (A21) vendorId: 0x1423 productId: 0x00AA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/205" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x00CD - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/265" deviceLabel: Ceiling 13 vendorId: 0x1423 productId: 0x0109 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/266" deviceLabel: LIFX Ceiling 13 vendorId: 0x1423 productId: 0x010A - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/267" deviceLabel: LIFX Mirror vendorId: 0x1423 productId: 0x010B - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/268" deviceLabel: LIFX Mirror vendorId: 0x1423 productId: 0x010C - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level #LG - id: "4142/8784" From 922a001a2cd96e91e5477a7e1216c90dfb492ea3 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:39:40 -0500 Subject: [PATCH 06/10] persist electrical tags that are used for profiling (#3038) --- drivers/SmartThings/matter-switch/src/switch_utils/utils.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 8e21c8baf8..d2457bfb4d 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -431,7 +431,7 @@ function utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sens table.sort(associated_endpoint_ids) local primary_associated_ep_id = associated_endpoint_ids[1] -- map the required electrical tags for this electrical sensor EP with the first associated EP ID, used later during profling. - utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags) + utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags, {persist = true}) utils.set_field_for_endpoint(device, fields.ASSIGNED_CHILD_KEY, electrical_sensor_ep.endpoint_id, string.format("%d", primary_associated_ep_id), { persist = true }) return true end From 2067aa5a91a6b3241c7cee3d1df8b1398b60aadc Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:39:53 -0500 Subject: [PATCH 07/10] Add Initial FP400 profile and subdriver (#3024) --- .../matter-sensor/fingerprints.yml | 5 + .../matter-sensor/profiles/aqara-fp400.yml | 14 ++ .../matter-sensor/src/sensor_utils/fields.lua | 6 + .../matter-sensor/src/sensor_utils/utils.lua | 11 ++ .../matter-sensor/src/sub_drivers.lua | 1 + .../sub_drivers/aqara_fp400/can_handle.lua | 12 ++ .../src/sub_drivers/aqara_fp400/init.lua | 23 +++ .../src/test/test_matter_aqara_fp400.lua | 141 ++++++++++++++++++ 8 files changed, 213 insertions(+) create mode 100644 drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index 483b92fc21..45b21dd386 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -15,6 +15,11 @@ matterManufacturer: vendorId: 0x115F productId: 0x2005 deviceProfileName: presence-illuminance-temperature-humidity-battery + - id: "4447/8201" + deviceLabel: Spatial Multi-Sensor FP400 + vendorId: 0x115F + productId: 0x2009 + deviceProfileName: aqara-fp400 #Bosch - id: 4617/12309 deviceLabel: "Door/window contact II [M]" diff --git a/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml new file mode 100644 index 0000000000..c2d5b7b037 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml @@ -0,0 +1,14 @@ +name: aqara-fp400 +components: +- id: main + capabilities: + - id: presenceSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua index b31b1b5c5b..232e431277 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua @@ -49,4 +49,10 @@ SensorFields.BOOLEAN_CAP_EVENT_MAP = { } } +SensorFields.vendor_overrides = { + [0x115F] = { -- AQARA_MANUFACTURER_ID + [0x2009] = { is_aqara_fp400 = true } + } +} + return SensorFields diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua index d5437410a5..513c9f4602 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua @@ -1,6 +1,8 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local fields = require "sensor_utils.fields" + local utils = {} -- Sanity check bounds for soil moisture measurement limits (percent) @@ -15,6 +17,15 @@ function utils.set_field_for_endpoint(device, field, endpoint, value, additional device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) end +function utils.get_product_override_field(device, override_key) + if device.manufacturer_info + and fields.vendor_overrides[device.manufacturer_info.vendor_id] + and fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id] + then + return fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id][override_key] + end +end + function utils.tbl_contains(array, value) if value == nil then return false end for _, element in pairs(array or {}) do diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua index 3bf4c46f73..aa8b046927 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua @@ -6,5 +6,6 @@ local sub_drivers = { lazy_load_if_possible("sub_drivers.air_quality_sensor"), lazy_load_if_possible("sub_drivers.smoke_co_alarm"), lazy_load_if_possible("sub_drivers.bosch_button_contact"), + lazy_load_if_possible("sub_drivers.aqara_fp400"), } return sub_drivers diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua new file mode 100644 index 0000000000..d28a66f147 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_fp400(opts, driver, device) + local sensor_utils = require "sensor_utils.utils" + if sensor_utils.get_product_override_field(device, "is_aqara_fp400") then + return true, require("sub_drivers.aqara_fp400") + end + return false +end + +return is_aqara_fp400 diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua new file mode 100644 index 0000000000..5f47c86047 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua @@ -0,0 +1,23 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local Fp400LifecycleHandlers = {} + +-- overwrite to avoid unnecessary metadata update calls +function Fp400LifecycleHandlers.do_configure() end + +-- overwrite to avoid unnecessary metadata update calls +function Fp400LifecycleHandlers.driver_switched(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +local aqara_fp400_handler = { + NAME = "aqara-fp400", + lifecycle_handlers = { + doConfigure = Fp400LifecycleHandlers.do_configure, + driverSwitched = Fp400LifecycleHandlers.driver_switched, + }, + can_handle = require("sub_drivers.aqara_fp400.can_handle"), +} + +return aqara_fp400_handler diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua new file mode 100644 index 0000000000..7f384c744e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua @@ -0,0 +1,141 @@ +-- 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.matter.clusters" +local t_utils = require "integration_test.utils" + +local matter_endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.IlluminanceMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + } +} + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqara-fp400.yml"), + manufacturer_info = { + vendor_id = 0x115F, + product_id = 0x2009, + }, + endpoints = matter_endpoints +}) + +local function subscribe_on_init(dev) + local subscribe_request = clusters.OccupancySensing.attributes.Occupancy:subscribe(dev) + subscribe_request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(dev)) + return subscribe_request +end + +local function test_init() + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = subscribe_on_init(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test no profile change on doConfigure for FP400", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + -- The FP400 sub-driver overrides doConfigure to be a no-op + -- When doConfigure completes successfully, the framework automatically provisions the device + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test no profile change on driverSwitched for FP400", + function() + local current_profile = mock_device.profile.id + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + -- The FP400 sub-driver overrides driverSwitched to only update provisioning state + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + -- Ensure profile has not changed + test.wait_for_events() + assert(mock_device.profile.id == current_profile, "Profile should not change on driverSwitched") + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Occupancy reports should generate correct presence messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 1, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 1, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Illuminance reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 21370) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() From 219c1d34efa5d86f724225a58723d42b16d2ccca Mon Sep 17 00:00:00 2001 From: thinkaName <144081204+thinkaName@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:28:30 +0800 Subject: [PATCH 08/10] add firstled-io_M4S4BAC (#2971) --- .../zigbee-switch/fingerprints.yml | 6 + .../switch-button-light-restore-wireless.yml | 39 ++ .../profiles/switch-button-wireless.yml | 17 + .../switch-light-restore-wireless.yml | 37 ++ .../profiles/switch-wireless.yml | 15 + .../src/firstled-io/can_handle.lua | 12 + .../src/firstled-io/fingerprints.lua | 9 + .../zigbee-switch/src/firstled-io/init.lua | 209 ++++++++ .../zigbee-switch/src/sub_drivers.lua | 3 +- .../src/test/test_firstled_switch.lua | 481 ++++++++++++++++++ tools/localizations/cn.csv | 1 + 11 files changed, 828 insertions(+), 1 deletion(-) create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index 90862aa61f..b44a13d3c1 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -2451,6 +2451,12 @@ zigbeeManufacturer: manufacturer: JNL model: Y-K002-001 deviceProfileName: basic-switch + #FIRSTLED + - id: "FIRSTLED/M4S4BAC" + deviceLabel: Mirror Series 4x4 1 + manufacturer: FIRSTLED + model: M4S4BAC + deviceProfileName: switch-light-restore-wireless zigbeeGeneric: - id: "genericSwitch" deviceLabel: Zigbee Switch diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml new file mode 100644 index 0000000000..f98b552b87 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml @@ -0,0 +1,39 @@ +name: switch-button-light-restore-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - title: "背光灯(backlight/백라이트)" + name: backlight + description: "背光灯(backlight/백라이트)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "人体接近感应(proximity detection/근접 감지)" + default: 2 + - title: "开关上电状态(relay powerOn state)" + name: powerOnStatus + description: "开关上电状态(relay powerOn state/릴레이 초기 동작 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 2 + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml new file mode 100644 index 0000000000..c48290e1a8 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml @@ -0,0 +1,17 @@ +name: switch-button-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml new file mode 100644 index 0000000000..b2e002d690 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml @@ -0,0 +1,37 @@ +name: switch-light-restore-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - title: "背光灯(backlight/백라이트)" + name: backlight + description: "背光灯(backlight/백라이트)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "人体接近感应(proximity detection/근접 감지)" + default: 2 + - title: "开关上电状态(relay powerOn state)" + name: powerOnStatus + description: "开关上电状态(relay powerOn state/릴레이 초기 동작 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 2 + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml new file mode 100644 index 0000000000..cb4be470f9 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml @@ -0,0 +1,15 @@ +name: switch-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua new file mode 100644 index 0000000000..5ad2d32a4b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/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("firstled-io.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("firstled-io") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua new file mode 100644 index 0000000000..2586782642 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--The number of children determines the number of sub-devices to be created. Each sub-device has the capability to switch between a switch and a button. +--The number of buttons determines how many buttons devices will be created. +--The driver supports a series of device combinations, such as 4+4, 3+3, 2+2, 4+0, etc., of switch and button type products. +return { + { mfr = "FIRSTLED", model = "M4S4BAC", children = 4, buttons = 4, child_profile = "switch-wireless" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua new file mode 100644 index 0000000000..6348f254b2 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua @@ -0,0 +1,209 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local device_lib = require "st.device" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local Scenes = zcl_clusters.Scenes +local PRIVATE_CLUSTER_ID = 0xFCCA +local MFG_CODE = 0x1235 +local FINGERPRINTS = require("firstled-io.fingerprints") + +--stse.changeToWirelessSwitch +--switch mode:The local switch and the app can control the relay.The button capability is not working. +--wirelessSwitch mode:The local switch does not control the relay. Once triggered, it will report to the system as "RecallScene",emit_event button.pushed. The relay can be controlled via the app. +local preference_map = { + ["backlight"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0000, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + }, + ["powerOnStatus"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0001, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + }, + ["stse.changeToWirelessSwitch"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0002, + mfg_code = MFG_CODE, + data_type = data_types.Boolean + } +} + +local function is_parent_device(device) + local parent = device:get_parent_device() + return parent == nil +end + +--If it is in the switch mode, only the switch will be displayed. If it is in the wirelessSwitch mode, both the switch and the button will be displayed. +local function toggle_button_visibility(device, show) + local is_parent = is_parent_device(device) + if is_parent then + if show then + device:try_update_metadata({profile = "switch-button-light-restore-wireless"}) + else + device:try_update_metadata({profile = "switch-light-restore-wireless"}) + end + else + if show then + device:try_update_metadata({profile = "switch-button-wireless"}) + else + device:try_update_metadata({profile = "switch-wireless"}) + end + end +end + +--When stse.changeToWirelessSwitch switching to the wirelessSwitch mode to listen for profile changes +local function listen_profile_button_transition(device, args) + local current_has = device:supports_capability(capabilities.button, "main") + + local old_has = false + if args and args.old_st_store and args.old_st_store.profile then + local old_main = args.old_st_store.profile.components.main + if old_main and old_main.capabilities then + old_has = old_main.capabilities["button"] ~= nil + end + end + --Capabilities button from non-existent to existing + if not old_has and current_has then + device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = {displayed = false}})) + device:emit_event(capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}})) + end +end + +local function device_info_changed(driver, device, event, args) + listen_profile_button_transition(device, args) + + local preferences = device.preferences + local old_preferences = args.old_st_store.preferences + if preferences ~= nil then + for id, attr in pairs(preference_map) do + local old_value = old_preferences[id] + local value = preferences[id] + if value ~= nil and value ~= old_value then + if attr.data_type == data_types.Uint8 then + value = tonumber(value) + end + device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, + attr.mfg_code, attr.data_type, value)) + --Switch to the corresponding profile based on stse.changeToWirelessSwitch + if id == "stse.changeToWirelessSwitch" then + toggle_button_visibility(device, value) + end + end + end + end +end + +local function get_children_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.children + end + end +end + +local function get_button_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.buttons + end + end +end + +local function get_child_profile_name(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.child_profile + end + end +end + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end + +--Create composite switches such as 4+4, 1+1, 4+0, 3+0 children+button +local function device_added(driver, device) + -- Create the corresponding number of child devices based on the value of fingerprint.children + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + local children_amount = get_children_amount(device) + if children_amount >= 2 then + for i = 2, children_amount, 1 do + if find_child(device, i) == nil then + local name = string.format("%s%d", string.sub(device.label, 0, -2), i) + local child_profile = get_child_profile_name(device) + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + -- Create the corresponding number of button devices based on the value of fingerprint.buttons + local button_amount = get_button_amount(device) + if button_amount >= 1 then + for i = children_amount + 1, children_amount + button_amount, 1 do + if find_child(device, i) == nil then + local name = string.format("%s%d", string.sub(device.label, 0, -2), i) + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = "button", + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name, + } + driver:try_create_device(metadata) + end + end + end + elseif device.network_type == "DEVICE_EDGE_CHILD" then + device:emit_event(capabilities.button.numberOfButtons({ value = 1 }, + { visibility = { displayed = false } })) + device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } })) + end +end + +local function scenes_cluster_handler(driver, device, zb_rx) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.button.button.pushed({ state_change = true })) +end + +local function device_init(self, device) + -- for multiple switch + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + device:set_find_child(find_child) + end +end + +local firstled_switch_handler = { + NAME = "FIRSTLED Switch Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = device_info_changed + }, + zigbee_handlers = { + cluster = { + [Scenes.ID] = { + [Scenes.server.commands.RecallScene.ID] = scenes_cluster_handler, + } + } + }, + can_handle = require("firstled-io.can_handle"), +} + +return firstled_switch_handler diff --git a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua index 69be094da4..b29c746d71 100644 --- a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua @@ -36,5 +36,6 @@ return { lazy_load_if_possible("frient"), lazy_load_if_possible("frient-IO"), lazy_load_if_possible("color_temp_range_handlers"), - lazy_load_if_possible("stateless_handlers") + lazy_load_if_possible("stateless_handlers"), + lazy_load_if_possible("firstled-io") } diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua new file mode 100644 index 0000000000..3a3c4782ea --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua @@ -0,0 +1,481 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local frameCtrl = require "st.zigbee.zcl.frame_ctrl" +local device_lib = require "st.device" + +local OnOff = clusters.OnOff +local Scenes = clusters.Scenes + +local PRIVATE_CLUSTER_ID = 0xFCCA +local MFG_CODE = 0x1235 +local FINGERPRINTS = require("firstled-io.fingerprints") + +local parent_profile = t_utils.get_profile_definition("switch-button-light-restore-wireless.yml") +local child_switch_profile = t_utils.get_profile_definition("switch-button-wireless.yml") + +local function get_children_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.children + end + end +end + +local function get_button_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.buttons + end + end +end + +local function get_child_profile_name(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.child_profile + end + end +end + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end +-- ====================== Mock Devices ====================== +local mock_parent = test.mock_device.build_test_zigbee_device({ + profile = parent_profile, + manufacturer = "FIRSTLED", + model = "M4S4BAC", + label = "Mirror Series 4x4 1", + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { id = 1, manufacturer = "FIRSTLED", model = "M4S4BAC", server_clusters = { 0x0004, 0x0006 } } + } +}) + +local mock_children = {} + +for i = 2, 4 do + local name = string.format("%s%d", string.sub("Mirror Series 4x4 1", 0, -2), i) + table.insert(mock_children, test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + profile = child_switch_profile, + label = name, + device_network_id = string.format("%04X:%02X", mock_parent:get_short_address(), i), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name + })) +end + +local function test_init() + test.mock_device.add_test_device(mock_parent) + for _, child in ipairs(mock_children) do + test.mock_device.add_test_device(child) + end +end + +test.set_test_init_function(test_init) + +-- ====================== can_handle ====================== +test.register_coroutine_test("can_handle should return true and handler for matching device", function() + local can_handle = require("firstled-io.can_handle") + local result, handler = can_handle({}, nil, mock_parent) + assert(result == true) + assert(handler ~= nil) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("can_handle should return false for non-matching device", function() + local can_handle = require("firstled-io.can_handle") + local non_match = test.mock_device.build_test_zigbee_device({ + manufacturer = "OTHER", model = "OTHER", profile = parent_profile + }) + local result = can_handle({}, nil, non_match) + assert(result == false) + end, + { + min_api_version = 19 + } +) + +-- ====================== Lifecycle ====================== +test.register_coroutine_test("device_init should set find_child for parent", function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + end, + { + min_api_version = 19 + } +) + +-- ====================== device_added ====================== +test.register_coroutine_test("device_added - Zigbee Parent should create children and emit capabilities", function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "added"}) + if mock_parent.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + local children_amount = get_children_amount(mock_parent) + if children_amount >= 2 then + for i = 2, children_amount, 1 do + if find_child(mock_parent, i) == nil then + local name = string.format("%s%d", string.sub(mock_parent.label, 0, -2), i) + local expected_metadata = { + type = "EDGE_CHILD", + label = name, + profile = get_child_profile_name(mock_parent), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + } + mock_parent:expect_device_create(expected_metadata) + end + end + end + local button_amount = get_button_amount(mock_parent) + if button_amount >= 1 then + for i = children_amount + 1, children_amount + button_amount, 1 do + if find_child(mock_parent, i) == nil then + local name = string.format("%s%d", string.sub(mock_parent.label, 0, -2), i) + local expected_metadata = { + type = "EDGE_CHILD", + label = name, + profile = "button", + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + } + mock_parent:expect_device_create(expected_metadata) + end + end + end + + elseif mock_parent.network_type == "DEVICE_EDGE_CHILD" then + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end + end, + { + min_api_version = 19 + } +) + +local function test_device_added_child(ep, name) + test.register_coroutine_test(name, function() + local child = mock_children[ep-1] + test.socket.device_lifecycle:__queue_receive({child.id, "added"}) + + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end, + { + min_api_version = 19 + } +) +end + +for ep = 2, 4 do + test_device_added_child(ep, "test_device_added_child endpoint " .. ep) +end + +-- ====================== Preferences ====================== +test.register_coroutine_test("infoChanged - backlight 0", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "0" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - backlight 1", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "1" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - backlight 2", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "2" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 0", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "0" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "1" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 2", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "2" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - stse.changeToWirelessSwitch true", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = true }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, true) + }) + mock_parent:expect_metadata_update({ profile = "switch-button-light-restore-wireless" }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - stse.changeToWirelessSwitch false", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = false }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, false) + }) + mock_parent:expect_metadata_update({ profile = "switch-light-restore-wireless" }) + end, + { + min_api_version = 19 + } +) + +local function test_child_changeToWirelessSwitch_true(ep, name) + test.register_coroutine_test(name, function() + test.socket.device_lifecycle:__queue_receive(mock_children[ep]:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = true }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, true):to_endpoint(ep+1) + }) + mock_children[ep]:expect_metadata_update({ profile = "switch-button-wireless" }) + end, + { + min_api_version = 19 + } + ) +end + +local function test_child_changeToWirelessSwitch_false(ep, name) + test.register_coroutine_test(name, function() + test.socket.device_lifecycle:__queue_receive(mock_children[ep]:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = false }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, false):to_endpoint(ep+1) + }) + mock_children[ep]:expect_metadata_update({ profile = "switch-wireless" }) + end, + { + min_api_version = 19 + } + ) +end + +for ep = 1, 3 do + test_child_changeToWirelessSwitch_true(ep, "children infoChanged - stse.changeToWirelessSwitch true " .. ep + 1) +end + +for ep = 1, 3 do + test_child_changeToWirelessSwitch_false(ep, "children infoChanged - stse.changeToWirelessSwitch false " .. ep + 1) +end + +-- ====================== Commands ====================== +test.register_message_test("Parent device - On command", { + { channel = "device_lifecycle", direction = "receive", message = { mock_parent.id, "init" }}, + { channel = "capability", direction = "receive", message = { mock_parent.id, { capability = "switch", component = "main", command = "on", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_parent.id, capability_id = "switch", capability_cmd_id = "on" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.On(mock_parent):to_endpoint(0x01) }} + }, + { + min_api_version = 19 + } +) + +test.register_message_test("Parent device - Off command", { + { channel = "capability", direction = "receive", message = { mock_parent.id, { capability = "switch", component = "main", command = "off", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_parent.id, capability_id = "switch", capability_cmd_id = "off" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.Off(mock_parent):to_endpoint(0x01) }} + }, + { + min_api_version = 19 + } +) + +-- ====================== Attribute Reports ====================== +test.register_coroutine_test( + "OnOff report on parent endpoint", + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, true):from_endpoint(0x01) + test.socket.zigbee:__queue_receive({mock_parent.id, report}) + + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", capabilities.switch.switch.on())) + mock_parent:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "OnOff report off parent endpoint", + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, false):from_endpoint(0x01) + test.socket.zigbee:__queue_receive({mock_parent.id, report}) + + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", capabilities.switch.switch.off())) + mock_parent:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } +) + +local function test_on_cmd(ep, name) + test.register_message_test(name, { + { channel = "capability", direction = "receive", message = { mock_children[ep].id, { capability = "switch", component = "main", command = "on", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_children[ep].id, capability_id = "switch", capability_cmd_id = "on" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.On(mock_parent):to_endpoint(ep+1) }} + }, + { + min_api_version = 19 + }) +end + +local function test_off_cmd(ep, name) + test.register_message_test(name, { + { channel = "capability", direction = "receive", message = { mock_children[ep].id, { capability = "switch", component = "main", command = "off", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_children[ep].id, capability_id = "switch", capability_cmd_id = "off" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.Off(mock_parent):to_endpoint(ep+1) }} + }, + { + min_api_version = 19 + }) +end + +local function test_onoff_report_on_cmd(ep, name) + test.register_coroutine_test( + name, + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, true):from_endpoint(ep+1) + test.socket.zigbee:__queue_receive({mock_children[ep].id, report}) + + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", capabilities.switch.switch.on())) + mock_children[ep]:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } + ) +end + +local function test_onoff_report_off_cmd(ep, name) + test.register_coroutine_test( + name, + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, false):from_endpoint(ep+1) + test.socket.zigbee:__queue_receive({mock_children[ep].id, report}) + + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", capabilities.switch.switch.off())) + mock_children[ep]:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } + ) +end + +-- ====================== RecallScene ====================== +local function test_recall_scene(ep, name) + test.register_coroutine_test(name, function() + local cmd = Scenes.server.commands.RecallScene.build_test_rx(mock_parent, 0xF0F0, ep) + cmd.body.zcl_header.frame_ctrl = frameCtrl(0x11) + test.socket.zigbee:__queue_receive({ mock_parent.id, cmd }) + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 19 + }) +end + +local function test_child_recall_scene(ep, name) + test.register_coroutine_test(name, function() + local cmd = Scenes.server.commands.RecallScene.build_test_rx(mock_parent, 0xF0F0, ep + 1) + cmd.body.zcl_header.frame_ctrl = frameCtrl(0x11) + test.socket.zigbee:__queue_receive({ mock_children[ep].id, cmd }) + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 19 + }) +end + +for ep = 1, 1 do + test_recall_scene(ep, "RecallScene on parent endpoint " .. ep) +end + +for ep = 1, 3 do + test_child_recall_scene(ep, "test_child_recall_scene on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_on_cmd(ep, "children test_on_cmd on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_off_cmd(ep, "children test_off_cmd on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_onoff_report_on_cmd(ep, "children test_onoff_report_on_cmd endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_onoff_report_off_cmd(ep, "children test_onoff_report_off_cmd endpoint " .. ep + 1) +end + +test.run_registered_tests() diff --git a/tools/localizations/cn.csv b/tools/localizations/cn.csv index a67fdfeddf..d1a3ed3369 100644 --- a/tools/localizations/cn.csv +++ b/tools/localizations/cn.csv @@ -140,3 +140,4 @@ Aqara Wireless Mini Switch T1,Aqara 无线开关 T1 "MultiIR Smart button MIR-SO100",麦乐克智能按钮MIR-SO100 "MultiIR Smoke Detector MIR-SM200",麦乐克烟雾报警器MIR-SM200 "MultiIR Siren MIR-SR100",麦乐克声光报警器MIR-SR100 +"Mirror Series 4x4 1",镜系列4x4 1 From ba0ed5a9824b6d05609395f60ec379a3470bc225 Mon Sep 17 00:00:00 2001 From: Harrison Carter <137556605+hcarter-775@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:48:22 -0500 Subject: [PATCH 09/10] add global scroll state for ikea scroll (#3039) --- .../scroll_handlers/event_handlers.lua | 4 ++- .../ikea_scroll/scroll_utils/event_utils.lua | 26 +++++++++++++++++++ .../ikea_scroll/scroll_utils/fields.lua | 9 +++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua index a6a7ce9168..dbad323784 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua @@ -17,7 +17,9 @@ local function rotate_amount_event_helper(device, endpoint_id, num_presses_to_ha local scroll_direction = switch_utils.tbl_contains(scroll_fields.ENDPOINTS_UP_SCROLL, endpoint_id) and 1 or -1 local scroll_amount = st_utils.clamp_value(scroll_direction * scroll_fields.PER_SCROLL_EVENT_ROTATION * num_presses_to_handle, -100, 100) - device:emit_event_for_endpoint(endpoint_id, capabilities.knob.rotateAmount(scroll_amount, {state_change = true})) + if event_utils.is_valid_scroll_amount(device, scroll_amount) then + device:emit_event_for_endpoint(endpoint_id, capabilities.knob.rotateAmount(scroll_amount, {state_change = true})) + end end -- Used by ENDPOINTS_UP_SCROLL and ENDPOINTS_DOWN_SCROLL, not ENDPOINTS_PUSH diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua index c838ad5c67..8a7a684cf6 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua @@ -1,10 +1,36 @@ -- Copyright © 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local st_utils = require "st.utils" local clusters = require "st.matter.clusters" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" local IkeaScrollEventUtils = {} + +function IkeaScrollEventUtils.requeue_clear_scroll_state(device) + -- cancel any previously queued clear state actions to prevent unintended clears + if device:get_field(scroll_fields.CLEAR_STATE_TIMER) then + device.thread:cancel_timer(device:get_field(scroll_fields.CLEAR_STATE_TIMER)) + end + local new_timer = device.thread:call_with_delay(scroll_fields.CLEAR_STATE_DELAY_S, function() + device:set_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE, 0) + end) + device:set_field(scroll_fields.CLEAR_STATE_TIMER, new_timer) +end + +function IkeaScrollEventUtils.is_valid_scroll_amount(device, scroll_amount) + local global_rotate_amount_state = device:get_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE) or 0 + local is_rotate_amount_state_at_bounds = (scroll_amount < 0 and global_rotate_amount_state <= -100) or (scroll_amount > 0 and global_rotate_amount_state >= 100) + if is_rotate_amount_state_at_bounds then + return false + end + + device:set_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE, st_utils.clamp_value(global_rotate_amount_state + scroll_amount, -100, 100)) + IkeaScrollEventUtils.requeue_clear_scroll_state(device) + return true +end + -- inspect all info blocks to find the last one that is not an InitialPress event. We will -- only try to emit a rotateAmount event if the current info block being handled is that last one. function IkeaScrollEventUtils.is_last_valid_info_block(cur_info_block_event_id, cur_info_block_value, info_blocks) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua index 451111f0fb..27e0dbcd6d 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua @@ -24,6 +24,15 @@ IkeaScrollFields.PER_SCROLL_EVENT_ROTATION = 6 -- Field to track the latest number of presses handled during a single scroll event sequence IkeaScrollFields.LATEST_NUMBER_OF_PRESSES_HANDLED = "__latest_number_of_presses_handled" +-- Field to track the global rotate amount state for the device to ensure no scroll events mapped outside of state bounds are emitted +IkeaScrollFields.GLOBAL_ROTATE_AMOUNT_STATE = "__global_rotate_amount_state" + +-- Stores a timer object, which is required to cancel a timer early +IkeaScrollFields.CLEAR_STATE_TIMER = "__clear_state_timer" + +-- Delay in seconds to wait before clearing the global rotate amount state after the last scroll event +IkeaScrollFields.CLEAR_STATE_DELAY_S = 8 + -- Required Events for the ENDPOINTS_PUSH. IkeaScrollFields.switch_press_subscribed_events = { clusters.Switch.events.InitialPress.ID, From ca15822ed89e44216814f5ed499b9720e6d17b9e Mon Sep 17 00:00:00 2001 From: seojune79 <119141897+seojune79@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:01:03 +0900 Subject: [PATCH 10/10] Aqara Bath Heater: localize device profiles (#3029) --- .../profiles/aqara-bath-heater.yml | 150 +----------------- drivers/Aqara/aqara-bath-heater/src/init.lua | 1 + .../src/test/test_aqara_bath_heater.lua | 2 + 3 files changed, 6 insertions(+), 147 deletions(-) diff --git a/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml index d130be5947..2acda35491 100644 --- a/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml +++ b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml @@ -8,165 +8,18 @@ components: version: 1 - id: colorTemperature version: 1 - config: - values: - - key: "colorTemperature.value" - range: [2700, 6500] - id: thermostatMode version: 1 - config: - values: - - key: "thermostatMode.value" - enabledValues: - - "off" - - "heat" - - "dryair" - - "cool" - - "fanonly" - id: thermostatHeatingSetpoint version: 1 - config: - values: - - key: "thermostatHeatingSetpoint.value" - range: [16, 45] - unit: "C" - id: fanOscillationMode version: 1 - config: - values: - - key: "fanOscillationMode.value" - enabledValues: - - "swing" - - "fixed" - id: fanMode version: 1 - config: - values: - - key: "fanMode.value" - enabledValues: - - "low" - - "medium" - - "high" - id: refresh version: 1 categories: - name: Thermostat -deviceConfig: - dashboard: - states: - - component: main - capability: switch - version: 1 - - component: main - capability: fanMode - version: 1 - actions: - - component: main - capability: switch - version: 1 - detailView: - - component: main - capability: switch - version: 1 - - component: main - capability: switchLevel - version: 1 - - component: main - capability: colorTemperature - version: 1 - - component: main - capability: thermostatMode - version: 1 - - component: main - capability: thermostatHeatingSetpoint - version: 1 - visibleCondition: - component: main - capability: thermostatMode - version: 1 - value: thermostatMode.value - operator: EQUALS - operand: "heat" - - component: main - capability: fanOscillationMode - version: 1 - visibleCondition: - component: main - capability: thermostatMode - version: 1 - value: thermostatMode.value - operator: ONE_OF - operand: '["heat", "dryair", "cool"]' - - component: main - capability: fanMode - version: 1 - visibleCondition: - component: main - capability: thermostatMode - version: 1 - value: thermostatMode.value - operator: ONE_OF - operand: '["heat", "dryair", "cool", "fanonly"]' - - component: main - capability: refresh - version: 1 - automation: - conditions: - - component: main - capability: switch - version: 1 - - component: main - capability: switchLevel - version: 1 - - component: main - capability: colorTemperature - version: 1 - - component: main - capability: thermostatMode - version: 1 - - component: main - capability: thermostatHeatingSetpoint - version: 1 - - component: main - capability: fanOscillationMode - version: 1 - - component: main - capability: fanMode - version: 1 - values: - - key: "fanMode.value" - enabledValues: - - "low" - - "medium" - - "high" - actions: - - component: main - capability: switch - version: 1 - - component: main - capability: switchLevel - version: 1 - - component: main - capability: colorTemperature - version: 1 - - component: main - capability: thermostatMode - version: 1 - - component: main - capability: thermostatHeatingSetpoint - version: 1 - - component: main - capability: fanOscillationMode - version: 1 - - component: main - capability: fanMode - version: 1 - values: - - key: "setFanMode.fanMode" - enabledValues: - - "low" - - "medium" - - "high" preferences: - preferenceId: stse.nightLightMode explicit: true @@ -178,3 +31,6 @@ preferences: explicit: true - preferenceId: stse.thermostatCtrl explicit: true +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_Bath_Heater diff --git a/drivers/Aqara/aqara-bath-heater/src/init.lua b/drivers/Aqara/aqara-bath-heater/src/init.lua index f543a704a8..62007b537c 100644 --- a/drivers/Aqara/aqara-bath-heater/src/init.lua +++ b/drivers/Aqara/aqara-bath-heater/src/init.lua @@ -429,6 +429,7 @@ local function device_added(driver, device) capabilities.fanOscillationMode.fanOscillationMode.NAME) == nil then device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.SWING)) end + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) end local function send_night_light(device, new) diff --git a/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua index 17e85b716b..8dfaa66974 100644 --- a/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua +++ b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua @@ -619,6 +619,8 @@ test.register_coroutine_test( capabilities.fanMode.fanMode("medium"))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} }))) end )